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

@@ -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"));
}