mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-11 02:27:21 +00:00
Added the BeyondFacts Lab as a prototype (#1175)
This commit is contained in:
1 parent
db8dae1cf6
commit
b6c3fbb5db
90 files changed
+5936
-209
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user