From a1b493cb1300cb8d2cf7e1f8c421c94dc5dc4060 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 12 Nov 2026 08:57:50 +0100 Subject: [PATCH] Fixed plugin values which depended on the region settings of the computer (#1194) --- .../DocumentAnalysisAssistant.razor.cs | 6 +- .../Assistants/I18N/allTexts.lua | 42 ++++---- .../Plugins/assistants/README.md | 8 +- .../Plugins/assistants/plugin.lua | 1 - .../plugin.lua | 42 ++++---- .../plugin.lua | 42 ++++---- .../Settings/ManagedConfiguration.Parsing.cs | 20 +++- .../DataModel/AssistantComponentPropHelper.cs | 54 ++++++++-- .../Assistants/DataModel/AssistantDropdown.cs | 30 ------ .../Assistants/PluginAssistants.cs | 40 +++++--- .../Tools/PluginSystem/PluginVersion.cs | 8 +- .../WebSearch/SearXNG/SearXNGSearchClient.cs | 9 +- .../ToolSettingsValueParser.cs | 6 +- .../wwwroot/changelog/v26.11.2.md | 3 +- app/SharedTools/LuaTable.cs | 7 +- .../Settings/ManagedValueCultureTests.cs | 48 +++++++++ app/Tests/Tools/LuaTableTests.cs | 26 +++++ .../AssistantPluginCultureTests.cs | 98 +++++++++++++++++++ .../ConfigurationSnippetParserTests.cs | 5 +- .../Tools/ToolCalling/SearXNGScoreTests.cs | 39 ++++++++ 20 files changed, 397 insertions(+), 137 deletions(-) create mode 100644 app/Tests/Settings/ManagedValueCultureTests.cs create mode 100644 app/Tests/Tools/PluginSystem/AssistantPluginCultureTests.cs create mode 100644 app/Tests/Tools/ToolCalling/SearXNGScoreTests.cs diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 2a21fad08..fb7378eca 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -539,7 +539,11 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore string.Join(";", dictionary.Keys .Cast() - .OrderBy(key => key.ToString(), StringComparer.Ordinal) - .Select(key => $"{key}:{dictionary[key]}")), + .OrderBy(SerializeManagedItem, StringComparer.Ordinal) + .Select(key => $"{SerializeManagedItem(key)}:{SerializeManagedItem(dictionary[key])}")), System.Collections.IEnumerable enumerable => string.Join(";", enumerable .Cast() - .Select(item => item.ToString() ?? string.Empty) + .Select(SerializeManagedItem) .Order(StringComparer.Ordinal)), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty, }; + /// + /// Writes a key, a value, or an item of a collection the same way on every computer, as the scalars above. + /// + /// + /// For the types of today's settings, i.e., whole numbers, enums, and texts, this is the form they were stored in + /// before; a number with a fraction or a date would otherwise follow the region settings of the computer. + /// + private static string SerializeManagedItem(object? item) => item switch + { + null => string.Empty, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => item.ToString() ?? string.Empty, + }; + private static Dictionary CloneStringDictionary(IDictionary values) => new(values, StringComparer.Ordinal); } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs index b53a3f3ea..f05c7af08 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs @@ -1,3 +1,5 @@ +using System.Globalization; + using AIStudio.Tools.PluginSystem.Assistants.Icons; namespace AIStudio.Tools.PluginSystem.Assistants.DataModel; @@ -9,26 +11,58 @@ internal static class AssistantComponentPropHelper /// private const string LUCIDE_PREFIX = "lucide:"; + /// + /// Reads a prop as text. A number, which Lua hands over as a double, becomes the same text on every computer, + /// the one Lua itself writes: 12.5 stays "12.5", also where the region settings use a decimal comma. + /// public static string ReadString(Dictionary props, string key) { - if (props.TryGetValue(key, out var value)) - return value.ToString() ?? string.Empty; + if (!props.TryGetValue(key, out var value)) + return string.Empty; - return string.Empty; + if (value is IFormattable formattable) + return formattable.ToString(null, CultureInfo.InvariantCulture); + + return value.ToString() ?? string.Empty; } public static void WriteString(Dictionary props, string key, string value) => props[key] = value; - public static int ReadInt(Dictionary props, string key, int fallback = 0) - { - return props.TryGetValue(key, out var value) && int.TryParse(value.ToString(), out var i) ? i : fallback; - } - + public static int ReadInt(Dictionary props, string key, int fallback = 0) => TryReadInt(props, key, out var number) ? number : fallback; + public static void WriteInt(Dictionary props, string key, int value) => props[key] = value; - public static int? ReadNullableInt(Dictionary props, string key) + public static int? ReadNullableInt(Dictionary props, string key) => TryReadInt(props, key, out var number) ? number : null; + + /// + /// Reads a whole number, independent of the region settings of the computer. + /// + /// + /// Lua hands over every number as a double, and the setters of the components store an int. A plugin may also + /// write the number as text. A number with a fraction, or one beyond the range of an int, counts as missing. + /// + private static bool TryReadInt(Dictionary props, string key, out int number) { - return props.TryGetValue(key, out var value) && int.TryParse(value.ToString(), out var i) ? i : null; + number = 0; + if (!props.TryGetValue(key, out var value)) + return false; + + switch (value) + { + case int whole: + number = whole; + return true; + + case double real when double.IsInteger(real) && real is >= int.MinValue and <= int.MaxValue: + number = (int)real; + return true; + + case string text: + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out number); + + default: + return false; + } } public static void WriteNullableInt(Dictionary props, string key, int? value) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantDropdown.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantDropdown.cs index 2eb4a1e1a..26b050e12 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantDropdown.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantDropdown.cs @@ -32,14 +32,6 @@ internal sealed class AssistantDropdown : StatefulAssistantComponentBase set => this.Props[nameof(this.Items)] = value; } - public string ValueType - { - get => this.Props.TryGetValue(nameof(this.ValueType), out var v) - ? v.ToString() ?? "string" - : "string"; - set => this.Props[nameof(this.ValueType)] = value; - } - public bool IsMultiselect { get => AssistantComponentPropHelper.ReadBool(this.Props, nameof(this.IsMultiselect)); @@ -141,28 +133,6 @@ internal sealed class AssistantDropdown : StatefulAssistantComponentBase return [this.Default, .. this.Items]; } - public IEnumerable GetParsedDropdownValues() - { - foreach (var item in this.Items) - { - switch (this.ValueType.ToLowerInvariant()) - { - case "int": - if (int.TryParse(item.Value, out var i)) yield return i; - break; - case "double": - if (double.TryParse(item.Value, out var d)) yield return d; - break; - case "bool": - if (bool.TryParse(item.Value, out var b)) yield return b; - break; - default: - yield return item.Value; - break; - } - } - } - public string Class { get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Class)); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs index b7728e5e6..5b8cde855 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Globalization; using AIStudio.Assistants; using AIStudio.Tools.PluginSystem.Assistants.DataModel; using AIStudio.Tools.PluginSystem.Assistants.DataModel.Layout; @@ -856,21 +857,7 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType this.State.Environment["DateTime"] = new LuaFunction((context, _) => { var format = context.ArgumentCount > 0 ? context.GetArgument(0) : "yyyy-MM-dd HH:mm:ss"; - var now = DateTime.Now; - var formattedDate = now.ToString(format); - - var table = new LuaTable - { - ["year"] = now.Year, - ["month"] = now.Month, - ["day"] = now.Day, - ["hour"] = now.Hour, - ["minute"] = now.Minute, - ["second"] = now.Second, - ["millisecond"] = now.Millisecond, - ["formatted"] = formattedDate, - }; - return new(context.Return(table)); + return new(context.Return(CreateDateTimeTable(DateTime.Now, format))); }); this.State.Environment["Timestamp"] = new LuaFunction((context, _) => @@ -889,6 +876,29 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType }); } + /// + /// Builds the table which the Lua function DateTime returns for the given moment. + /// + /// + /// The formatted text is the same on every computer. With the culture of the computer, the separators, the + /// calendar, and the names of months and days would follow its region settings, e.g., the year of the Buddhist + /// era in Thailand. + /// + /// The moment to describe. + /// The .NET format of the formatted text. + /// The parts of the moment and the formatted text. + internal static LuaTable CreateDateTimeTable(DateTime moment, string format) => new() + { + ["year"] = moment.Year, + ["month"] = moment.Month, + ["day"] = moment.Day, + ["hour"] = moment.Hour, + ["minute"] = moment.Minute, + ["second"] = moment.Second, + ["millisecond"] = moment.Millisecond, + ["formatted"] = moment.ToString(format, CultureInfo.InvariantCulture) + }; + private static void InitializeState(IEnumerable components, AssistantState state) { foreach (var component in components) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs index d5507a562..7b3bf8525 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs @@ -1,4 +1,6 @@ // ReSharper disable MemberCanBePrivate.Global +using System.Globalization; + namespace AIStudio.Tools.PluginSystem; /// @@ -46,9 +48,9 @@ public readonly record struct PluginVersion(int Major, int Minor, int Patch) : I if (segments.Length != 3) throw new FormatException("The input string must be in the format 'major.minor.patch'."); - var major = int.Parse(segments[0]); - var minor = int.Parse(segments[1]); - var patch = int.Parse(segments[2]); + var major = int.Parse(segments[0], NumberStyles.Integer, CultureInfo.InvariantCulture); + var minor = int.Parse(segments[1], NumberStyles.Integer, CultureInfo.InvariantCulture); + var patch = int.Parse(segments[2], NumberStyles.Integer, CultureInfo.InvariantCulture); if(major < 0 || minor < 0 || patch < 0) throw new FormatException("The major, minor, and patch numbers must be greater than or equal to 0."); diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/SearXNG/SearXNGSearchClient.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/SearXNG/SearXNGSearchClient.cs index b7cafa7ce..57938c672 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/SearXNG/SearXNGSearchClient.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/SearXNG/SearXNGSearchClient.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Text; using System.Text.Json; @@ -208,7 +209,11 @@ internal sealed class SearXNGSearchClient private static string ReadNodeString(JsonNode? node) => node is null ? string.Empty : node.ToString().Trim(); - private static bool TryGetScore(JsonObject result, out double score) + /// + /// An instance may also send the score as text. It comes in the notation of JSON, so the region + /// settings of the computer must not read it: with a decimal comma, "0.75" would become 75. + /// + internal static bool TryGetScore(JsonObject result, out double score) { score = double.MinValue; if (!result.TryGetPropertyValue("score", out var scoreNode) || scoreNode is null) @@ -219,7 +224,7 @@ internal sealed class SearXNGSearchClient JsonValue value when value.TryGetValue(out var doubleScore) => ReturnScore(doubleScore, out score), JsonValue value when value.TryGetValue(out var decimalScore) => ReturnScore((double)decimalScore, out score), JsonValue value when value.TryGetValue(out var intScore) => ReturnScore(intScore, out score), - _ => double.TryParse(scoreNode.ToString(), out var parsedScore) && ReturnScore(parsedScore, out score), + _ => double.TryParse(scoreNode.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedScore) && ReturnScore(parsedScore, out score), }; } diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolSettingsValueParser.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolSettingsValueParser.cs index 36e981999..d970a4846 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolSettingsValueParser.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolSettingsValueParser.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace AIStudio.Tools.ToolCallingSystem; internal static class ToolSettingsValueParser @@ -7,7 +9,7 @@ internal static class ToolSettingsValueParser if (!settingsValues.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) return null; - return int.TryParse(value, out var parsedValue) && parsedValue > 0 ? parsedValue : null; + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) && parsedValue > 0 ? parsedValue : null; } public static bool TryReadOptionalPositiveInt( @@ -23,7 +25,7 @@ internal static class ToolSettingsValueParser if (!settingsValues.TryGetValue(key, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) return true; - if (int.TryParse(rawValue, out var parsedValue) && parsedValue > 0) + if (int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) && parsedValue > 0) { value = parsedValue; return true; diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md index 82272fa41..9c7c688cb 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md @@ -1,4 +1,5 @@ # v26.11.2, build 261 (2026-11-xx xx:xx UTC) - Added the BeyondFacts Lab as a prototype. It helps researchers prepare step by step for a challenging appearance, such as a panel discussion, an interview, or a hearing. An introduction explains how to work with the lab, and a chat tells you whether the lab fits your task. Each event gets a preparation of its own, which leads you through seven steps, from the details of the event to what you take away, and shows you which key details are still missing. In each step, a chat guides you with questions, based on your details and a guide on science in public debates. You can add material which every step takes into account, such as papers, the recording of an interview, or a poster. Your preparations are part of your data backups. To try it, open the app settings, allow preview features down to prototype, and enable the BeyondFacts Lab. Thanks to Lara Wüster from the Technical University of Munich (TUM) for the concept and the content of the lab. - Added Lucide icons to assistant plugins: name an icon as `lucide:` followed by its name on lucide.dev, such as `lucide:house`. The names of MudBlazor icons keep working, so existing plugins need no change. -- Changed the icons of AI Studio to Lucide, an open-source icon set. All icons now share one style, and each feature shows the same icon wherever you meet it. \ No newline at end of file +- Changed the icons of AI Studio to Lucide, an open-source icon set. All icons now share one style, and each feature shows the same icon wherever you meet it. +- Fixed assistant plugins which wrote numbers and dates according to the region settings of your computer. Plugins now show them the same way on every computer: a decimal number such as `12.5` keeps its point, and the `DateTime` helper writes the names of months in English. \ No newline at end of file diff --git a/app/SharedTools/LuaTable.cs b/app/SharedTools/LuaTable.cs index a0c6d110e..fbebf7fee 100644 --- a/app/SharedTools/LuaTable.cs +++ b/app/SharedTools/LuaTable.cs @@ -12,7 +12,12 @@ public static class LuaTable // Add the UI_TEXT_CONTENT table: // sb.AppendLineLF($$"""{{tableVariableName}} = {}"""); - foreach (var kvp in keyValuePairs.OrderBy(x => x.Key)) + + // + // Ordinal, so that every computer writes the texts in the same order. A comparison by culture would + // depend on the region settings of the computer, e.g., where it sorts the underscore: + // + foreach (var kvp in keyValuePairs.OrderBy(x => x.Key, StringComparer.Ordinal)) { if (cancellationToken.IsCancellationRequested) return sb.ToString(); diff --git a/app/Tests/Settings/ManagedValueCultureTests.cs b/app/Tests/Settings/ManagedValueCultureTests.cs new file mode 100644 index 000000000..da04e75f1 --- /dev/null +++ b/app/Tests/Settings/ManagedValueCultureTests.cs @@ -0,0 +1,48 @@ +using System.Globalization; + +using AIStudio.Settings; + +namespace AIStudio.Tests.Settings; + +/// +/// Checks that the stored form of a managed setting does not depend on the region settings of the computer. +/// +/// +/// The settings keep the last value an organization applied as an editable default in this form, and compare it with +/// the current value to see whether the user changed it. A form which followed the culture would make the value look +/// changed once the computer, or its region settings, changed. All cases run under de-DE, which writes a decimal comma. +/// +[TestFixture] +[SetCulture("de-DE")] +public sealed class ManagedValueCultureTests +{ + [Test] + public void TheCultureWritesADecimalComma() + { + Assert.That(1.5.ToString(CultureInfo.CurrentCulture), Is.EqualTo("1,5"), "Without a decimal comma, the other cases check nothing."); + } + + [Test] + public void TheItemsOfAListAreWrittenTheSameOnEveryComputer() + { + Assert.That(ManagedConfiguration.SerializeManagedScalarValue(new List { 2.25, 1.5 }), Is.EqualTo("1.5;2.25")); + } + + [Test] + public void TheKeysAndValuesOfATableAreWrittenTheSameOnEveryComputer() + { + var table = new Dictionary { [2] = 0.5, [10] = 1.25 }; + + Assert.That(ManagedConfiguration.SerializeManagedScalarValue(table), Is.EqualTo("10:1.25;2:0.5")); + } + + [Test] + public void WholeNumbersKeepTheFormTheyWereStoredInBefore() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(ManagedConfiguration.SerializeManagedScalarValue(new List { 12, 3 }), Is.EqualTo("12;3")); + Assert.That(ManagedConfiguration.SerializeManagedScalarValue(new Dictionary { [1] = "one" }), Is.EqualTo("1:one")); + } + } +} \ No newline at end of file diff --git a/app/Tests/Tools/LuaTableTests.cs b/app/Tests/Tools/LuaTableTests.cs index 703696197..33cbe2db7 100644 --- a/app/Tests/Tools/LuaTableTests.cs +++ b/app/Tests/Tools/LuaTableTests.cs @@ -84,6 +84,32 @@ public sealed class LuaTableTests """.ReplaceLineEndings("\n"))); } + /// + /// The order of the texts must not depend on the region settings of the computer which writes them. + /// + /// + /// A comparison by culture sorts the underscore before the letters, the ordinal one after them. The + /// language plugins were written in the first order before, so this test fails with it. + /// + [Test] + [SetCulture("de-DE")] + public void TheTextsAreOrderedOrdinallyWhateverTheCulture() + { + var code = Create(new Dictionary + { + ["UI_TEXT_CONTENT.AISTUDIO.DIALOGS.A_B.T1"] = "With an underscore", + ["UI_TEXT_CONTENT.AISTUDIO.DIALOGS.AB.T1"] = "Without an underscore" + }); + + var withoutUnderscore = code.IndexOf("AISTUDIO::DIALOGS::AB::T1", StringComparison.Ordinal); + var withUnderscore = code.IndexOf("AISTUDIO::DIALOGS::A_B::T1", StringComparison.Ordinal); + using (Assert.EnterMultipleScope()) + { + Assert.That(withoutUnderscore, Is.GreaterThanOrEqualTo(0), code); + Assert.That(withUnderscore, Is.GreaterThan(withoutUnderscore), code); + } + } + private static string Create(IReadOnlyDictionary texts, IReadOnlyDictionary? comments = null) { // The Lua runtime has a type of the same name, which stands for a table in Lua: diff --git a/app/Tests/Tools/PluginSystem/AssistantPluginCultureTests.cs b/app/Tests/Tools/PluginSystem/AssistantPluginCultureTests.cs new file mode 100644 index 000000000..9fd025371 --- /dev/null +++ b/app/Tests/Tools/PluginSystem/AssistantPluginCultureTests.cs @@ -0,0 +1,98 @@ +using System.Globalization; + +using AIStudio.Tools.PluginSystem.Assistants; +using AIStudio.Tools.PluginSystem.Assistants.DataModel; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks that assistant plugins read numbers and write dates the same way on every computer. +/// +/// +/// The app sets no culture of its own, so the current culture is the one of the operating system. Each case runs +/// under a culture which writes differently than the invariant one: de-DE with a decimal comma and German names of +/// months, sv-SE with a minus sign of its own (U+2212), fi-FI with a period between hours and minutes, and th-TH +/// with the years of the Buddhist era. Each case first checks that its culture really writes differently, so that +/// a system without the data of the cultures fails instead of passing. +/// +[TestFixture] +public sealed class AssistantPluginCultureTests +{ + private static readonly DateTime MOMENT = new(2026, 3, 11, 14, 3, 22, 500); + + [TestCase("de-DE", 12.5, "12.5")] + [TestCase("sv-SE", -3.5, "-3.5")] + public void ANumberFromLuaBecomesTheSameTextOnEveryComputer(string culture, double number, string expected) + { + InCulture(culture, () => + { + var props = new Dictionary { ["PrefillText"] = number }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(number.ToString(CultureInfo.CurrentCulture), Is.Not.EqualTo(expected), "The culture must write the number differently, or this case checks nothing."); + Assert.That(AssistantComponentPropHelper.ReadString(props, "PrefillText"), Is.EqualTo(expected)); + } + }); + } + + [TestCase("de-DE")] + [TestCase("sv-SE")] + public void AWholeNumberIsReadOnEveryComputer(string culture) + { + InCulture(culture, () => + { + var props = new Dictionary + { + ["FromLua"] = -3.0, + ["FromASetter"] = 7, + ["AsText"] = "-12", + ["WithAFraction"] = 6.5, + ["TooLarge"] = 1e15 + }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(AssistantComponentPropHelper.ReadInt(props, "FromLua"), Is.EqualTo(-3)); + Assert.That(AssistantComponentPropHelper.ReadInt(props, "FromASetter"), Is.EqualTo(7)); + Assert.That(AssistantComponentPropHelper.ReadInt(props, "AsText"), Is.EqualTo(-12)); + Assert.That(AssistantComponentPropHelper.ReadInt(props, "WithAFraction", fallback: 42), Is.EqualTo(42)); + Assert.That(AssistantComponentPropHelper.ReadNullableInt(props, "TooLarge"), Is.Null); + Assert.That(AssistantComponentPropHelper.ReadNullableInt(props, "Missing"), Is.Null); + } + }); + } + + [TestCase("fi-FI", "yyyy-MM-dd HH:mm:ss", "2026-03-11 14:03:22")] + [TestCase("th-TH", "yyyy-MM-dd HH:mm:ss", "2026-03-11 14:03:22")] + [TestCase("de-DE", "dd. MMMM yyyy", "11. March 2026")] + public void TheDateTimeHelperFormatsTheSameOnEveryComputer(string culture, string format, string expected) + { + InCulture(culture, () => + { + var table = PluginAssistants.CreateDateTimeTable(MOMENT, format); + + using (Assert.EnterMultipleScope()) + { + Assert.That(MOMENT.ToString(format, CultureInfo.CurrentCulture), Is.Not.EqualTo(expected), "The culture must write the moment differently, or this case checks nothing."); + Assert.That(table["formatted"].Read(), Is.EqualTo(expected)); + Assert.That(table["year"].Read(), Is.EqualTo(2026)); + Assert.That(table["millisecond"].Read(), Is.EqualTo(500)); + } + }); + } + + private static void InCulture(string name, Action check) + { + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(name); + check(); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs b/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs index e25c62ef1..d8d25e75f 100644 --- a/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs +++ b/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs @@ -24,9 +24,12 @@ namespace AIStudio.Tests.Tools.PluginSystem; /// tables are compared value by value. A parser which read a single string differently than Lua /// would hand the import something the configuration plugin of the same snippet never contains.

/// Nothing here asserts on the wording of an issue: I18N is process-wide state without a reset, so -/// an Init somewhere else would decide whether these pass. +/// an Init somewhere else would decide whether these pass.

+/// All cases run under de-DE, which writes a decimal comma: an export which wrote a number with the +/// region settings of the computer would fail the comparison with Lua here. /// [TestFixture] +[SetCulture("de-DE")] public sealed class ConfigurationSnippetParserTests { /// diff --git a/app/Tests/Tools/ToolCalling/SearXNGScoreTests.cs b/app/Tests/Tools/ToolCalling/SearXNGScoreTests.cs new file mode 100644 index 000000000..fceff4b75 --- /dev/null +++ b/app/Tests/Tools/ToolCalling/SearXNGScoreTests.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.SearXNG; + +namespace AIStudio.Tests.Tools.ToolCalling; + +/// +/// Checks that the score of a SearXNG result is read the same way on every computer. +/// +/// +/// The score decides the order of the results. An instance may send it as a number or as text, both in the notation +/// of JSON. All cases run under de-DE, where the period separates thousands: read with that culture, "0.75" was 75. +/// +[TestFixture] +[SetCulture("de-DE")] +public sealed class SearXNGScoreTests +{ + [Test] + public void TheCultureReadsThePeriodAsAThousandsSeparator() + { + Assert.That(double.Parse("0.75", CultureInfo.CurrentCulture), Is.EqualTo(75), "Without that, the other cases check nothing."); + } + + [TestCase("""{ "score": 0.75 }""")] + [TestCase("""{ "score": "0.75" }""")] + public void TheScoreIsReadTheSameOnEveryComputer(string json) + { + var result = JsonNode.Parse(json)!.AsObject(); + + var found = SearXNGSearchClient.TryGetScore(result, out var score); + + using (Assert.EnterMultipleScope()) + { + Assert.That(found, Is.True); + Assert.That(score, Is.EqualTo(0.75)); + } + } +} \ No newline at end of file