Added the BeyondFacts Lab as a prototype (#1175)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-08 19:49:46 +01:00
1 parent db8dae1cf6
commit b6c3fbb5db
90 files changed
+5936 -209

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks how AI Studio reads the files which come with it.
/// </summary>
/// <remarks>
/// The tests run a development build, which reads the files from its output folder: the project file copies every
/// embedded file there as well.
/// </remarks>
[TestFixture]
public sealed class AppResourcesTests
{
[Test]
public async Task AFileOfTheAppIsRead()
{
var text = await AppResources.ReadTextAsync("Assistants/PromptOptimizer/prompting_guideline.md");
Assert.That(text, Is.Not.Empty);
}
[Test]
public async Task BackslashesAndOuterSlashesAreAccepted()
{
var withSlashes = await AppResources.ReadTextAsync("Assistants/PromptOptimizer/prompting_guideline.md");
var withBackslashes = await AppResources.ReadTextAsync("/Assistants\\PromptOptimizer\\prompting_guideline.md");
Assert.That(withBackslashes, Is.EqualTo(withSlashes));
}
[Test]
public async Task AMissingFileGivesAnEmptyText()
{
var text = await AppResources.ReadTextAsync("Assistants/PromptOptimizer/does-not-exist.md");
Assert.That(text, Is.Empty);
}
[Test]
public void AFolderOfTheAppListsItsFiles()
{
var provider = AppResources.FolderProvider("Assistants/PromptOptimizer");
Assert.That(provider.GetDirectoryContents(string.Empty).Select(file => file.Name), Does.Contain("prompting_guideline.md"));
}
}
@@ -54,6 +54,7 @@ public sealed class DataBackupWriterTests
this.WriteDataFile("plugins/.lock", string.Empty);
this.WriteDataFile("tokenizers/openai-gpt-6/tokenizer.json", "{}");
this.WriteDataFile("visualBriefings/briefing/manifest.json", "{}");
this.WriteDataFile("beyondFactsLab/preparation/project.json", "{}");
this.WriteDataFile("media-staging/operation/transcript.md", "temporary");
this.WriteDataFile("databases/sqlite/rag-index.sqlite3", "the index while AI Studio writes to it");
this.WriteDataFile("databases/provenance/provenance.sqlite3", "the provenance store while AI Studio writes to it");
@@ -91,6 +92,7 @@ public sealed class DataBackupWriterTests
"data/plugins/my-assistant/.hidden.lua",
"data/tokenizers/openai-gpt-6/tokenizer.json",
"data/visualBriefings/briefing/manifest.json",
"data/beyondFactsLab/preparation/project.json",
"data/databases/provenance/provenance.sqlite3",
"data/databases/sqlite/rag-index.sqlite3",
"data/databases/vector_database/stores/store_1/segments/segment",
+88
View File
@@ -0,0 +1,88 @@
using System.Text;
using Lua;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks the Lua code which the I18N collector and the localization assistant write for the texts of the app.
/// </summary>
/// <remarks>
/// Both write each text as a comment followed by its assignment. A text with several lines, such as a notice in
/// Markdown, has to keep its line breaks, and its comment must not spill into the next line: Lua would read the rest
/// of the text as code there. Each case therefore runs the written code and compares what Lua reads with the text.
/// </remarks>
[TestFixture]
public sealed class LuaTableTests
{
private const string KEY = "UI_TEXT_CONTENT.AISTUDIO.TOOLS.NOTICE.T123";
private const string LUA_KEY = "AISTUDIO::TOOLS::NOTICE::T123";
[TestCase("## Welcome!\n\nThe first paragraph.\nThe second line.", "## Welcome!\n\nThe first paragraph.\nThe second line.")]
[TestCase("## Welcome!\r\n\r\nWindows line endings", "## Welcome!\n\nWindows line endings")]
[TestCase("An old Mac line ending\rin between", "An old Mac line ending\nin between")]
[TestCase("Quotes \"like these\",\na backslash \\,\nand -- no comment", "Quotes \"like these\",\na backslash \\,\nand -- no comment")]
public async Task LuaReadsATextWithSeveralLinesWithItsLineBreaks(string text, string expected)
{
var code = Create(new Dictionary<string, string> { [KEY] = text });
Assert.That(await ReadAsync(code, LUA_KEY), Is.EqualTo(expected), code);
}
[Test]
public void TheCommentOfATextWithSeveralLinesStaysOnOneLine()
{
var code = Create(new Dictionary<string, string> { [KEY] = "## Welcome!\r\n\r\nThe first paragraph." });
Assert.That(code.ReplaceLineEndings("\n").Split('\n'), Does.Contain("-- ## Welcome! The first paragraph."), code);
}
[Test]
public async Task TheCommentComesFromTheGivenContentAndStaysOnOneLine()
{
var comments = new Dictionary<string, string> { [KEY] = "## Welcome!\n\nThe first paragraph." };
var code = Create(new Dictionary<string, string> { [KEY] = "## Willkommen!\n\nDer erste Absatz." }, comments);
using (Assert.EnterMultipleScope())
{
Assert.That(code.ReplaceLineEndings("\n").Split('\n'), Does.Contain("-- ## Welcome! The first paragraph."), code);
Assert.That(await ReadAsync(code, LUA_KEY), Is.EqualTo("## Willkommen!\n\nDer erste Absatz."), code);
}
}
[Test]
public void TextsOnOneLineAreWrittenAsBefore()
{
var code = Create(new Dictionary<string, string>
{
[KEY] = "Save \"this\" in C:\\Data ",
["UI_TEXT_CONTENT.AISTUDIO.TOOLS.NOTICE.T456"] = "Cancel"
});
Assert.That(code.ReplaceLineEndings("\n"), Is.EqualTo(
"""
UI_TEXT_CONTENT = {}
-- Save "this" in C:\Data
UI_TEXT_CONTENT["AISTUDIO::TOOLS::NOTICE::T123"] = "Save \"this\" in C:\\Data"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::TOOLS::NOTICE::T456"] = "Cancel"
""".ReplaceLineEndings("\n")));
}
private static string Create(IReadOnlyDictionary<string, string> texts, IReadOnlyDictionary<string, string>? comments = null)
{
// The Lua runtime has a type of the same name, which stands for a table in Lua:
var builder = new StringBuilder();
return SharedTools.LuaTable.Create(ref builder, "UI_TEXT_CONTENT", texts, comments);
}
private static async Task<string> ReadAsync(string code, string key)
{
using var state = LuaState.Create();
await state.DoStringAsync(code);
return state.Environment["UI_TEXT_CONTENT"].Read<LuaTable>()[key].Read<string>();
}
}
@@ -0,0 +1,127 @@
using Microsoft.CodeAnalysis.CSharp;
using SharedTools;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks that the I18N collector reads string literals exactly as the compiler does.
/// </summary>
/// <remarks>
/// The collector files a text under the hash of what it reads, and the app looks it up by the hash of the string the
/// compiler made. Every case therefore compares the reader with Roslyn, the compiler itself.
/// </remarks>
[TestFixture]
public sealed class CSharpStringLiteralTests
{
private static readonly string[] LITERALS =
[
// Regular literals, with every kind of escape sequence:
"\"Plain text\"",
"\"\"",
"\"Quotes \\\" and a backslash \\\\\"",
"\"Line\\nbreak and tab\\t and return\\r\"",
"\"All simple escapes: \\' \\0 \\a \\b \\e \\f \\v\"",
"\"Unicode \\u00FC and \\U0001F600\"",
"\"Hex \\x41, \\x041, \\x0041, and \\xA followed by text\"",
"\"Commas, \\\"quoted\\\", and parentheses (like this)\"",
// Verbatim literals:
"@\"C:\\Users\\Name\"",
"@\"Doubled \"\"quotes\"\" inside\"",
"@\"Over\nseveral\r\nlines\"",
// Single-line raw literals:
"\"\"\"Text with \"quotes\" inside\"\"\"",
"\"\"\"By clicking \"Accept GPL and Install,\" you agree\"\"\"",
"\"\"\"\"Text with \"\"\" inside\"\"\"\"",
"\"\"\"{} and \\n stay as they are\"\"\"",
// Multi-line raw literals:
"\"\"\"\n ## Welcome!\n\n The first paragraph.\n \"\"\"",
"\"\"\"\r\n CRLF line endings\r\n stay as they are\r\n \"\"\"",
"\"\"\"\n Indented\n more indented\n back\n \"\"\"",
"\"\"\"\n Before an empty line\n\n after it\n \"\"\"",
"\"\"\"\n Before a line of spaces\n \n after it\n \"\"\"",
"\"\"\"\n Before a line of more spaces\n \n after it\n \"\"\"",
"\"\"\" \n Spaces after the opening quotes\n \"\"\"",
"\"\"\"\n\tTabs as indentation\n\t\"\"\"",
"\"\"\"\n Quotes \"\" inside, and \"quoted\" text\n \"\"\"",
"\"\"\"\"\n Three quotes \"\"\" inside\n \"\"\"\"",
"\"\"\"\nNo indentation at all\n\"\"\"",
];
private static readonly string[] NO_LITERALS =
[
"Text without quotes",
"$\"Interpolated {value}\"",
"\"Unterminated",
"\"Line break\n inside a regular literal\"",
"\"Unknown escape \\q\"",
"\"Short unicode \\u12\"",
"@\"Unterminated verbatim",
"\"\"\"Unterminated raw",
"\"\"\"\n Content after the closing line is missing",
"\"\"\"\n Less indented than\n the closing line is not allowed\n \"\"\"",
];
[TestCaseSource(nameof(LITERALS))]
public void TheReaderGetsWhatTheCompilerGets(string literal)
{
var token = SyntaxFactory.ParseToken(literal);
Assume.That(token.ContainsDiagnostics, Is.False, "The case itself is no valid literal.");
var isRead = CSharpStringLiteral.TryRead($"{literal}) and more code", out var value, out var length);
using (Assert.EnterMultipleScope())
{
Assert.That(isRead, Is.True);
Assert.That(value, Is.EqualTo(token.ValueText));
Assert.That(length, Is.EqualTo(token.Span.Length));
}
}
[TestCaseSource(nameof(NO_LITERALS))]
public void SomethingTheCompilerRefusesOrComputesAtRuntimeIsNotRead(string source)
{
Assert.That(CSharpStringLiteral.TryRead(source, out _, out _), Is.False);
}
[Test]
public void TheCasesWhichAreNoLiteralsAreRefusedByTheCompilerAsWell()
{
using (Assert.EnterMultipleScope())
{
foreach (var source in NO_LITERALS.Where(source => source.StartsWith('"') || source.StartsWith("@\"", StringComparison.Ordinal)))
{
var token = SyntaxFactory.ParseToken(source);
Assert.That(token.ContainsDiagnostics || token.Span.Length < source.Length, Is.True, $"The compiler accepts: {source}");
}
}
}
[Test]
public void TheLiteralEndsWhereTheCompilerEndsIt()
{
const string SOURCE = "\"First\", \"Second\")";
var isRead = CSharpStringLiteral.TryRead(SOURCE, out var value, out var length);
using (Assert.EnterMultipleScope())
{
Assert.That(isRead, Is.True);
Assert.That(value, Is.EqualTo("First"));
Assert.That(SOURCE[length..], Is.EqualTo(", \"Second\")"));
}
}
[Test]
public void EveryLiteralIsAValidStringToken()
{
using (Assert.EnterMultipleScope())
{
foreach (var literal in LITERALS)
Assert.That(SyntaxFactory.ParseToken(literal).Kind(), Is.AnyOf(SyntaxKind.StringLiteralToken, SyntaxKind.SingleLineRawStringLiteralToken, SyntaxKind.MultiLineRawStringLiteralToken), literal);
}
}
}
@@ -0,0 +1,179 @@
using System.Runtime.CompilerServices;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
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.
/// </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
/// each text under the same hash, or the text stays English in every language without any notice. The checks over
/// the source of the app therefore compare every text the collector finds with what the compiler makes of it.
/// </remarks>
[TestFixture]
public sealed class I18NSourceScannerTests
{
[Test]
public void EveryTextOfTheAppIsReadAsTheCompilerReadsIt()
{
var texts = 0;
var mismatches = new List<string>();
foreach (var file in SourceFiles())
{
var content = File.ReadAllText(file);
var warnings = new List<string>();
foreach (var text in I18NSourceScanner.FindTexts(content, file, warnings))
{
texts++;
var token = SyntaxFactory.ParseToken(content, text.Offset);
if (token.ContainsDiagnostics || token.HasLeadingTrivia || token.Span.Length != text.Length || token.ValueText != text.Text)
mismatches.Add($"{Path.GetRelativePath(AppSourceDirectory(), file)} at {text.Offset}: '{text.Text}' instead of '{token.ValueText}'");
}
}
using (Assert.EnterMultipleScope())
{
Assert.That(texts, Is.GreaterThan(5_000), "The test found too few texts to be looking at the source of the app.");
Assert.That(mismatches, Is.Empty);
}
}
[Test]
public void EveryCallWithATextIsFound()
{
var missing = new List<string>();
var calls = 0;
foreach (var file in SourceFiles().Where(file => file.EndsWith(".cs", StringComparison.Ordinal)))
{
var content = File.ReadAllText(file);
if (!content.Contains("T(", StringComparison.Ordinal))
continue;
var found = I18NSourceScanner.FindTexts(content, file, []).Select(text => text.Offset).ToHashSet();
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)
continue;
// The collector takes only a text which follows the parenthesis right away:
if (literal.SpanStart != invocation.ArgumentList.OpenParenToken.Span.End)
continue;
calls++;
if (!found.Contains(literal.SpanStart))
missing.Add($"{Path.GetRelativePath(AppSourceDirectory(), file)}: {literal}");
}
}
using (Assert.EnterMultipleScope())
{
Assert.That(calls, Is.GreaterThan(2_000), "The test found too few calls to be looking at the source of the app.");
Assert.That(missing, Is.Empty);
}
}
[Test]
public void TheSourceOfTheAppCausesNoWarning()
{
var warnings = new List<string>();
foreach (var file in SourceFiles())
I18NSourceScanner.FindTexts(File.ReadAllText(file), Path.GetRelativePath(AppSourceDirectory(), file), warnings);
Assert.That(warnings, Is.Empty);
}
[Test]
public void AMultiLineTextIsFoundWithoutItsIndentation()
{
const string SOURCE = """"
public string Notice => TB("""
## Welcome!
The first paragraph.
""");
"""";
var texts = I18NSourceScanner.FindTexts(SOURCE, "Notice.cs", []);
Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "## Welcome!\n\nThe first paragraph." }));
}
[Test]
public void AllKindsOfCallsAreFound()
{
const string SOURCE = """
T("First"); TB("Second"); this.T("Third", typeof(X).Namespace, nameof(X)); @T(@"Fourth"); T( "Not found");
""";
var texts = I18NSourceScanner.FindTexts(SOURCE, "Calls.cs", []);
Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "First", "Second", "Third", "Fourth" }));
}
[Test]
public void ACallOfAnotherMethodIsIgnored()
{
const string SOURCE = """GetT("No text"); MyTB("No text either"); T("Text");""";
var texts = I18NSourceScanner.FindTexts(SOURCE, "Other.cs", []);
Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "Text" }));
}
[TestCase("""T($"Hello {name}");""")]
[TestCase("""T("Hello " + name);""")]
[TestCase("""T("Unterminated);""")]
public void ATextWhichCannotBeTranslatedCausesAWarning(string source)
{
var warnings = new List<string>();
var texts = I18NSourceScanner.FindTexts(source, "Warning.cs", warnings);
using (Assert.EnterMultipleScope())
{
Assert.That(texts, Is.Empty);
Assert.That(warnings, Has.Count.EqualTo(1));
}
}
[Test]
public void LineEndingsDoNotChangeTheKey()
{
using (Assert.EnterMultipleScope())
{
Assert.That(I18NKeyHash.Of("First line\r\nsecond line"), Is.EqualTo(I18NKeyHash.Of("First line\nsecond line")));
Assert.That(I18NKeyHash.Of("First line\rsecond line"), Is.EqualTo(I18NKeyHash.Of("First line\nsecond line")));
}
}
[TestCase("Save")]
[TestCase("Parameter: ")]
[TestCase("""Invalid JSON: Add the parameters in proper JSON formatting, e.g., "temperature": 0.5.""")]
public void ATextWithoutLineBreaksKeepsItsKey(string text)
{
Assert.That(I18NKeyHash.Of(text), Is.EqualTo(text.ToFNV32()));
}
private static string? CalledName(InvocationExpressionSyntax invocation) => invocation.Expression switch
{
IdentifierNameSyntax identifier => identifier.Identifier.Text,
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
_ => null,
};
private static IEnumerable<string> SourceFiles()
{
var appDirectory = AppSourceDirectory();
return Directory.EnumerateFiles(appDirectory, "*.*", SearchOption.AllDirectories)
.Where(path => path.EndsWith(".cs", StringComparison.Ordinal) || path.EndsWith(".razor", StringComparison.Ordinal))
.Where(path => Path.GetRelativePath(appDirectory, path).Split(Path.DirectorySeparatorChar)[0] is not ("bin" or "obj" or "wwwroot"));
}
private static string AppSourceDirectory([CallerFilePath] string sourceFilePath = "") => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio"));
}