Added the informal address to the BeyondFacts Lab (#1176)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-08 21:10:51 +01:00
1 parent b6c3fbb5db
commit e77645f44c
48 files changed
+1254 -160

No files matched your search

@@ -7,11 +7,11 @@ using SourceCodeRules.UsageAnalyzers;
namespace AIStudio.Tests.Analyzers;
/// <summary>
/// Checks that the texts of the app are looked up only through a T or TB wrapper, whose fallback text the I18N
/// Checks that the texts of the app are looked up only through a T, TB, or TA wrapper, whose fallback text the I18N
/// collector finds.
/// </summary>
/// <remarks>
/// The collector reads the texts from calls of T and TB. A text handed to GetText anywhere else never reaches
/// The collector reads the texts from calls of T, TB, and TA. A text handed to GetText anywhere else never reaches
/// the language plugins, so it is never translated, and nothing at runtime shows it. MWAIS0012 refuses such a
/// call while compiling. GetText is an extension method, so these tests ask in both of the forms C# knows for
/// one: the classic method with a this parameter and the member of an extension block.
@@ -42,6 +42,7 @@ public sealed class DirectI18NGetTextAnalyzerTests
[TestCase(CLASSIC_FORM, "TB", TestName = "A TB wrapper around a classic extension method")]
[TestCase(EXTENSION_BLOCK_FORM, "T", TestName = "A T wrapper around the member of an extension block")]
[TestCase(EXTENSION_BLOCK_FORM, "TB", TestName = "A TB wrapper around the member of an extension block")]
[TestCase(EXTENSION_BLOCK_FORM, "TA", TestName = "A TA wrapper around the member of an extension block")]
public async Task AWrapperWhichForwardsItsTextIsAllowed(string form, string wrapper)
{
var reported = await AnalyzeAsync(form, $"public string {wrapper}(string fallbackEN) => this.GetText(fallbackEN);");
@@ -54,6 +55,7 @@ public sealed class DirectI18NGetTextAnalyzerTests
[TestCase(CLASSIC_FORM, """public string Describe() => ILangExtensions.GetText(this, "Hello");""", TestName = "A classic extension method called as a static method")]
[TestCase(EXTENSION_BLOCK_FORM, """public string Describe() => this.GetText("Hello");""", TestName = "The member of an extension block called from another method")]
[TestCase(EXTENSION_BLOCK_FORM, """public string T(string fallbackEN) => this.GetText("Hello");""", TestName = "The member of an extension block called by a wrapper with a text of its own")]
[TestCase(EXTENSION_BLOCK_FORM, """public string TA(string fallbackEN) => this.GetText("Hello");""", TestName = "The member of an extension block called by a TA wrapper with a text of its own")]
[TestCase(EXTENSION_BLOCK_FORM, """public string Describe() => ILangExtensions.GetText(this, "Hello");""", TestName = "The member of an extension block called as a static method")]
public async Task ADirectLookupIsRefused(string form, string member)
{
@@ -28,6 +28,8 @@ public sealed class ThisUsageAnalyzerTests
[TestCase("CITY", TestName = "A static field")]
[TestCase("ROOM", TestName = "A constant")]
[TestCase("Shorten(this.location)", TestName = "A static method")]
[TestCase("""T("Kickoff")""", TestName = "A text of T")]
[TestCase("""TA("Your meeting")""", TestName = "A text of TA")]
public async Task AMemberWhichNeedsNoThisIsLeftAlone(string expression)
{
var reported = await AnalyzeClassAsync(expression);
@@ -160,6 +162,10 @@ public sealed class ThisUsageAnalyzerTests
private string Describe() => this.Name;
private static string Shorten(string text) => text[..1];
private string T(string fallbackEN) => fallbackEN;
private string TA(string fallbackEN) => fallbackEN;
}
""";
@@ -0,0 +1,103 @@
using AIStudio.Assistants.I18N;
using SharedTools;
namespace AIStudio.Tests.Assistants.I18N;
/// <summary>
/// Checks how the localization assistant translates the two variants of a text of TA(), which addresses the reader.
/// </summary>
/// <remarks>
/// The informal variant starts from the formal translation, so that both read alike and a reviewed formal translation
/// keeps its wording. The formal translations of a run therefore come first.
/// </remarks>
[TestFixture]
public sealed class I18NAddressPromptTests
{
private const string KEY = "root::AISTUDIO::TESTS::TEXTS::T123";
private static readonly string INFORMAL_KEY = I18NAddressVariant.InformalKeyOf(KEY);
[Test]
public void TheInformalVariantsComeAfterAllOtherTexts()
{
var content = new Dictionary<string, string>
{
[INFORMAL_KEY] = "Enter your name.",
["root::A::T1"] = "Save",
[KEY] = "Enter your name.",
["root::A::T2::INFORMAL"] = "Check your input.",
["root::A::T2"] = "Check your input.",
};
var order = I18NAddressPrompt.FormalFirst(content).Select(entry => entry.Key);
Assert.That(order, Is.EqualTo(new[] { "root::A::T1", KEY, "root::A::T2", INFORMAL_KEY, "root::A::T2::INFORMAL" }));
}
[Test]
public void TheFormalTranslationOfThisRunComesFirst()
{
var translatedNow = new Dictionary<string, string> { [KEY] = "Geben Sie Ihren Namen ein." };
var translatedBefore = new Dictionary<string, string> { [KEY] = "Ihr Name, bitte." };
Assert.That(I18NAddressPrompt.FindFormalTranslation(INFORMAL_KEY, translatedNow, translatedBefore), Is.EqualTo("Geben Sie Ihren Namen ein."));
}
[Test]
public void TheFormalTranslationOfTheLanguagePluginCountsAsWell()
{
var translatedBefore = new Dictionary<string, string> { [KEY] = "Geben Sie Ihren Namen ein." };
Assert.That(I18NAddressPrompt.FindFormalTranslation(INFORMAL_KEY, new Dictionary<string, string>(), translatedBefore), Is.EqualTo("Geben Sie Ihren Namen ein."));
}
[Test]
public void WithoutAFormalTranslationThereIsNone()
{
using (Assert.EnterMultipleScope())
{
Assert.That(I18NAddressPrompt.FindFormalTranslation(INFORMAL_KEY, new Dictionary<string, string>(), new Dictionary<string, string> { [KEY] = " " }), Is.Null);
Assert.That(I18NAddressPrompt.FindFormalTranslation(KEY, new Dictionary<string, string> { [KEY] = "Geben Sie Ihren Namen ein." }, new Dictionary<string, string>()), Is.Null, "A usual key is no informal variant.");
}
}
[Test]
public void AUsualTextAddressesTheReaderFormally()
{
var section = I18NAddressPrompt.Section("German (Germany)", false, null);
using (Assert.EnterMultipleScope())
{
Assert.That(section, Does.Contain("formal address of German (Germany)"));
Assert.That(section, Does.Not.Contain("informal"));
}
}
[Test]
public void AnInformalVariantChangesOnlyTheAddressOfTheFormalTranslation()
{
const string FORMAL = "## Willkommen!\n\nGeben Sie Ihren Namen ein.";
// The line endings of the prompt are those of the checkout:
var section = I18NAddressPrompt.Section("German (Germany)", true, FORMAL).ReplaceLineEndings("\n");
using (Assert.EnterMultipleScope())
{
Assert.That(section, Does.Contain("informal address of German (Germany)"));
Assert.That(section, Does.Contain($"<formal_translation>\n{FORMAL}\n</formal_translation>"));
Assert.That(section, Does.Contain("change only what the informal address"));
}
}
[Test]
public void AnInformalVariantWithoutAFormalTranslationIsTranslatedInformally()
{
var section = I18NAddressPrompt.Section("French (France)", true, null);
using (Assert.EnterMultipleScope())
{
Assert.That(section, Does.Contain("informal address of French (France)"));
Assert.That(section, Does.Not.Contain("formal_translation"));
}
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ public sealed class BflAccessTests
{
var forms = ConfigurationSelectDataFactory.GetBeyondFactsLabAddressFormData().Select(data => data.Value);
Assert.That(forms, Is.EquivalentTo(Enum.GetValues<BflAddressForm>()));
Assert.That(forms, Is.EquivalentTo(Enum.GetValues<AddressForm>()));
}
private static SettingsManager CreateSettingsManager() => new(NullLogger<SettingsManager>.Instance, null!);
@@ -0,0 +1,49 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using SharedTools;
namespace AIStudio.Tests.BeyondFactsLab;
/// <summary>
/// Checks that the texts of the BeyondFacts Lab follow the form of address the person chose.
/// </summary>
/// <remarks>
/// A text which says "you" in English addresses the reader in other languages as well, so it needs TA(). With T() or
/// TB(), it would stay formal in German, although the person chose the informal address.
/// </remarks>
[TestFixture]
public sealed partial class BflAddressTests
{
[Test]
public void EveryTextOfTheLabWhichSaysYouFollowsTheFormOfAddress()
{
var texts = 0;
var formalOnly = new List<string>();
foreach (var file in LabSourceFiles())
{
foreach (var text in I18NSourceScanner.FindTexts(File.ReadAllText(file), file, []))
{
texts++;
if (!text.HasAddressVariants && YouRegex().IsMatch(text.Text))
formalOnly.Add($"{Path.GetFileName(file)}: {text.Text}");
}
}
using (Assert.EnterMultipleScope())
{
Assert.That(texts, Is.GreaterThan(80), "The test found too few texts to be looking at the lab.");
Assert.That(formalOnly, Is.Empty);
}
}
[GeneratedRegex(@"\byou(r|rs|rself)?\b", RegexOptions.IgnoreCase)]
private static partial Regex YouRegex();
private static IEnumerable<string> LabSourceFiles([CallerFilePath] string sourceFilePath = "")
{
var appDirectory = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "MindWork AI Studio"));
return Directory.EnumerateFiles(Path.Combine(appDirectory, "Assistants", "BeyondFactsLab"))
.Append(Path.Combine(appDirectory, "Dialogs", "Settings", "SettingsDialogBeyondFactsLab.razor"));
}
}
@@ -1,4 +1,3 @@
using AIStudio.Assistants.BeyondFactsLab;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
@@ -142,7 +141,7 @@ public sealed class BflComponentTests
[Test]
public void NewSettingsAddressFormally()
{
Assert.That(new DataBeyondFactsLab().AddressForm, Is.EqualTo(BflAddressForm.FORMAL), "The texts of AI Studio address the person formally as well.");
Assert.That(new DataBeyondFactsLab().AddressForm, Is.EqualTo(AddressForm.FORMAL), "The texts of AI Studio address the person formally as well.");
}
private static SettingsManager CreateSettingsManager() => new(NullLogger<SettingsManager>.Instance, null!);
+12
View File
@@ -50,6 +50,18 @@ public sealed class LuaTableTests
}
}
[Test]
public async Task TheKeyOfAnInformalVariantKeepsItsEnding()
{
var code = Create(new Dictionary<string, string>
{
[KEY] = "Enter your name.",
[SharedTools.I18NAddressVariant.InformalKeyOf(KEY)] = "Enter your name.",
});
Assert.That(await ReadAsync(code, $"{LUA_KEY}::INFORMAL"), Is.EqualTo("Enter your name."), code);
}
[Test]
public void TextsOnOneLineAreWrittenAsBefore()
{
@@ -0,0 +1,33 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// A language plugin with texts of its own and a base language behind it, as PluginLanguage has.
/// </summary>
/// <param name="content">The texts of the language itself.</param>
/// <param name="baseContent">The texts of the base language, which answer for the keys the language lacks.</param>
public sealed class FakeLanguagePlugin(IReadOnlyDictionary<string, string> content, IReadOnlyDictionary<string, string>? baseContent = null) : ILanguagePlugin
{
/// <inheritdoc />
public bool TryGetText(string key, out string value, bool logWarning = false)
{
if (content.TryGetValue(key, out var text) || baseContent is not null && baseContent.TryGetValue(key, out text))
{
value = text;
return true;
}
value = string.Empty;
return false;
}
/// <inheritdoc />
public string IETFTag => "de-DE";
/// <inheritdoc />
public string LangName => "Deutsch (Deutschland)";
/// <inheritdoc />
public IReadOnlyDictionary<string, string> Content => content;
}
@@ -0,0 +1,132 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using SharedTools;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks the two variants of a text of TA(), which addresses the reader: their keys, and which of them the app shows.
/// </summary>
/// <remarks>
/// The informal variant has a key of its own. When the language in use lacks it, the person reads the formal variant
/// of that language, never the English text which the base language holds under the informal key.
/// </remarks>
[TestFixture]
public sealed class I18NAddressVariantTests
{
private const string NAMESPACE = "AIStudio.Tests";
private const string TYPE = "Texts";
private const string TEXT = "Enter your name.";
private const string FORMAL = "Geben Sie Ihren Namen ein.";
private const string INFORMAL = "Gib deinen Namen ein.";
private static readonly string KEY = $"root::AISTUDIO::TESTS::TEXTS::T{I18NKeyHash.Of(TEXT)}";
private static readonly string INFORMAL_KEY = I18NAddressVariant.InformalKeyOf(KEY);
[Test]
public void TheKeyOfTheInformalVariantLeadsBackToTheUsualKey()
{
using (Assert.EnterMultipleScope())
{
Assert.That(INFORMAL_KEY, Is.EqualTo($"{KEY}::INFORMAL"));
Assert.That(I18NAddressVariant.TryGetFormalKey(INFORMAL_KEY, out var formalKey), Is.True);
Assert.That(formalKey, Is.EqualTo(KEY));
}
}
[Test]
public void AUsualKeyBelongsToNoInformalVariant()
{
using (Assert.EnterMultipleScope())
{
Assert.That(I18NAddressVariant.TryGetFormalKey(KEY, out var formalKey), Is.False);
Assert.That(formalKey, Is.EqualTo(KEY));
}
}
[Test]
public void TheInformalVariantShowsWhenThePersonWantsIt()
{
var plugin = new FakeLanguagePlugin(new Dictionary<string, string> { [KEY] = FORMAL, [INFORMAL_KEY] = INFORMAL });
using (Assert.EnterMultipleScope())
{
Assert.That(Lookup(plugin, AddressForm.INFORMAL), Is.EqualTo(INFORMAL));
Assert.That(Lookup(plugin, AddressForm.FORMAL), Is.EqualTo(FORMAL));
Assert.That(I18N.I.GetText(plugin, TEXT, NAMESPACE, TYPE), Is.EqualTo(FORMAL), "Texts of T() address the reader formally.");
}
}
[Test]
public void WithoutAnInformalVariantTheFormalTextOfTheLanguageShows()
{
// The English language plugin, the base of every other one, holds the informal variant in English:
var plugin = new FakeLanguagePlugin(
new Dictionary<string, string> { [KEY] = FORMAL },
new Dictionary<string, string> { [KEY] = TEXT, [INFORMAL_KEY] = TEXT });
Assert.That(Lookup(plugin, AddressForm.INFORMAL), Is.EqualTo(FORMAL));
}
[Test]
public void AnEmptyInformalVariantDoesNotCount()
{
var plugin = new FakeLanguagePlugin(new Dictionary<string, string> { [KEY] = FORMAL, [INFORMAL_KEY] = " " });
Assert.That(Lookup(plugin, AddressForm.INFORMAL), Is.EqualTo(FORMAL));
}
[Test]
public void WithoutAnyTranslationTheEnglishTextShows()
{
var plugin = new FakeLanguagePlugin(new Dictionary<string, string>());
using (Assert.EnterMultipleScope())
{
Assert.That(Lookup(plugin, AddressForm.INFORMAL), Is.EqualTo(TEXT));
Assert.That(Lookup(plugin, AddressForm.FORMAL), Is.EqualTo(TEXT));
Assert.That(I18N.I.GetText(NoPluginLanguage.INSTANCE, TEXT, NAMESPACE, TYPE, AddressForm.INFORMAL), Is.EqualTo(TEXT));
}
}
[Test]
public void ALanguageWithAnInformalVariantOfItsOwnOffersTheChoice()
{
var german = new FakeLanguagePlugin(new Dictionary<string, string> { ["root::A::T1"] = "Speichern", [KEY] = FORMAL, [INFORMAL_KEY] = INFORMAL });
Assert.That(german.HasInformalAddress(), Is.True);
}
[Test]
public void ALanguageWithOneFormOfAddressOffersNoChoice()
{
// The localization assistant writes the informal variants of English in the same words:
var english = new FakeLanguagePlugin(new Dictionary<string, string> { [KEY] = TEXT, [INFORMAL_KEY] = TEXT });
Assert.That(english.HasInformalAddress(), Is.False);
}
[Test]
public void ALanguageWithoutInformalVariantsOffersNoChoice()
{
// The informal variant of the base language does not count for the language in use:
var german = new FakeLanguagePlugin(
new Dictionary<string, string> { [KEY] = FORMAL },
new Dictionary<string, string> { [KEY] = TEXT, [INFORMAL_KEY] = "Enter your name, please." });
using (Assert.EnterMultipleScope())
{
Assert.That(german.HasInformalAddress(), Is.False);
Assert.That(NoPluginLanguage.INSTANCE.HasInformalAddress(), Is.False);
}
}
[Test]
public void TheTextsAddressThePersonFormallyUntilTheAppSaysOtherwise()
{
Assert.That(I18N.I.AddressForm, Is.EqualTo(AddressForm.FORMAL), "Nothing in the tests sets where the form of address comes from.");
}
private static string Lookup(ILanguagePlugin plugin, AddressForm addressForm) => I18N.I.GetText(plugin, TEXT, NAMESPACE, TYPE, addressForm);
}
@@ -8,7 +8,7 @@ using SharedTools;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks how the I18N collector finds the texts of T() and TB(), and which key it files them under.
/// Checks how the I18N collector finds the texts of T(), TB(), and TA(), and which key it files them under.
/// </summary>
/// <remarks>
/// The app looks up a translation by the hash of the string the compiler made of a text. The collector has to file
@@ -58,7 +58,7 @@ public sealed class I18NSourceScannerTests
var root = CSharpSyntaxTree.ParseText(content).GetRoot();
foreach (var invocation in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
if (CalledName(invocation) is not ("T" or "TB") || invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not LiteralExpressionSyntax literal)
if (CalledName(invocation) is not ("T" or "TB" or "TA") ||invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not LiteralExpressionSyntax literal)
continue;
// The collector takes only a text which follows the parenthesis right away:
@@ -108,18 +108,28 @@ public sealed class I18NSourceScannerTests
public void AllKindsOfCallsAreFound()
{
const string SOURCE = """
T("First"); TB("Second"); this.T("Third", typeof(X).Namespace, nameof(X)); @T(@"Fourth"); T( "Not found");
T("First"); TB("Second"); this.T("Third", typeof(X).Namespace, nameof(X)); @T(@"Fourth"); TA("Fifth"); @TA("Sixth"); T( "Not found");
""";
var texts = I18NSourceScanner.FindTexts(SOURCE, "Calls.cs", []);
Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "First", "Second", "Third", "Fourth" }));
Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "First", "Second", "Third", "Fourth", "Fifth", "Sixth" }));
}
[Test]
public void OnlyATextOfTAGetsAVariantWithTheInformalAddress()
{
const string SOURCE = """T("Save"); TB("Your details"); TA("Enter your name."); TA(@"Please check ""this"".");""";
var texts = I18NSourceScanner.FindTexts(SOURCE, "Address.cs", []);
Assert.That(texts.Where(text => text.HasAddressVariants).Select(text => text.Text), Is.EqualTo(new[] { "Enter your name.", "Please check \"this\"." }));
}
[Test]
public void ACallOfAnotherMethodIsIgnored()
{
const string SOURCE = """GetT("No text"); MyTB("No text either"); T("Text");""";
const string SOURCE = """GetT("No text"); MyTB("No text either"); DATA("No text at all"); T("Text");""";
var texts = I18NSourceScanner.FindTexts(SOURCE, "Other.cs", []);