mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-11 01: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,469 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the BeyondFacts Lab stores its preparations on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// People maintain the list of their preparations themselves: nothing gets deleted on its own, also not a
|
||||
/// preparation which this version cannot open.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BeyondFactsLabStoreTests
|
||||
{
|
||||
private string dataDirectory = string.Empty;
|
||||
|
||||
private string RootDirectory => Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME);
|
||||
|
||||
[SetUp]
|
||||
public void CreateDataDirectory()
|
||||
{
|
||||
this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.dataDirectory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeleteDataDirectory()
|
||||
{
|
||||
if (Directory.Exists(this.dataDirectory))
|
||||
Directory.Delete(this.dataDirectory, true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ACreatedPreparationIsListedAndLoaded()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
|
||||
var created = await store.CreateAsync(" Panel on climate adaptation ");
|
||||
var projects = await store.ListProjectsAsync();
|
||||
var loaded = await store.LoadAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(created.Name, Is.EqualTo("Panel on climate adaptation"));
|
||||
Assert.That(created.SchemaVersion, Is.EqualTo(BflProject.CURRENT_SCHEMA_VERSION));
|
||||
Assert.That(created.HappenedAtUtc, Is.Null);
|
||||
Assert.That(projects, Has.Count.EqualTo(1));
|
||||
Assert.That(projects[0].IsAvailable, Is.True);
|
||||
Assert.That(projects[0].Name, Is.EqualTo(created.Name));
|
||||
Assert.That(loaded, Is.Not.Null);
|
||||
Assert.That(loaded!.Name, Is.EqualTo(created.Name));
|
||||
Assert.That(loaded.CreatedAtUtc, Is.EqualTo(created.CreatedAtUtc));
|
||||
Assert.That(File.Exists(this.ProjectPath(created.ProjectId)), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ANewStoreFindsThePreparationsOfTheLastOne()
|
||||
{
|
||||
var created = await this.CreateStore().CreateAsync("Interview");
|
||||
|
||||
var projects = await this.CreateStore().ListProjectsAsync();
|
||||
|
||||
Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { created.ProjectId }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThePreparationChangedLastComesFirst()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var first = await store.CreateAsync("First");
|
||||
var second = await store.CreateAsync("Second");
|
||||
|
||||
await store.RenameAsync(first.ProjectId, "First, renamed");
|
||||
var projects = await store.ListProjectsAsync();
|
||||
|
||||
Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { first.ProjectId, second.ProjectId }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RenamingKeepsEverythingElse()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Old name");
|
||||
|
||||
var renamed = await store.RenameAsync(created.ProjectId, "New name");
|
||||
var loaded = await store.LoadAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(renamed.Name, Is.EqualTo("New name"));
|
||||
Assert.That(loaded!.Name, Is.EqualTo("New name"));
|
||||
Assert.That(loaded.CreatedAtUtc, Is.EqualTo(created.CreatedAtUtc));
|
||||
Assert.That(loaded.ModifiedAtUtc, Is.GreaterThanOrEqualTo(created.ModifiedAtUtc));
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase("\a\t")]
|
||||
public async Task AnEmptyNameIsRefused(string name)
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () => await store.CreateAsync(name));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ANameLosesControlCharactersAndGetsCut()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
|
||||
var created = await store.CreateAsync("Talk\0 show" + new string('x', 300));
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(created.Name, Does.StartWith("Talk show"));
|
||||
Assert.That(created.Name, Has.Length.EqualTo(BeyondFactsLabStore.MAX_NAME_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheEventCanBeMarkedAsHavingTakenPlaceAndBack()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var happenedAt = new DateTimeOffset(2026, 12, 10, 14, 0, 0, TimeSpan.FromHours(1));
|
||||
|
||||
await store.SetHappenedAsync(created.ProjectId, happenedAt);
|
||||
var afterMarking = await store.LoadAsync(created.ProjectId);
|
||||
await store.SetHappenedAsync(created.ProjectId, null);
|
||||
var afterTakingBack = await store.LoadAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(afterMarking!.HappenedAtUtc, Is.EqualTo(happenedAt));
|
||||
Assert.That(afterMarking.HappenedAtUtc!.Value.Offset, Is.EqualTo(TimeSpan.Zero));
|
||||
Assert.That(afterTakingBack!.HappenedAtUtc, Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeletingRemovesTheFolderAndTheSelection()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var kept = await store.CreateAsync("Kept");
|
||||
var deleted = await store.CreateAsync("Deleted");
|
||||
await store.RememberSelectionAsync(deleted.ProjectId);
|
||||
|
||||
await store.DeleteAsync(deleted.ProjectId);
|
||||
var projects = await store.ListProjectsAsync();
|
||||
var newStore = this.CreateStore();
|
||||
await newStore.ListProjectsAsync();
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { kept.ProjectId }));
|
||||
Assert.That(Directory.Exists(Path.Combine(this.RootDirectory, deleted.ProjectId.ToString("D"))), Is.False);
|
||||
Assert.That(store.LastSelectedProjectId, Is.Null);
|
||||
Assert.That(newStore.LastSelectedProjectId, Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheSelectionSurvivesANewStore()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
await store.CreateAsync("Other");
|
||||
var selected = await store.CreateAsync("Selected");
|
||||
|
||||
await store.RememberSelectionAsync(selected.ProjectId);
|
||||
var newStore = this.CreateStore();
|
||||
await newStore.ListProjectsAsync();
|
||||
|
||||
Assert.That(newStore.LastSelectedProjectId, Is.EqualTo(selected.ProjectId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ASelectionOfAMissingPreparationIsDropped()
|
||||
{
|
||||
Directory.CreateDirectory(this.RootDirectory);
|
||||
await File.WriteAllTextAsync(Path.Combine(this.RootDirectory, "selection.json"), $"\"{Guid.NewGuid():D}\"");
|
||||
|
||||
var store = this.CreateStore();
|
||||
await store.ListProjectsAsync();
|
||||
|
||||
Assert.That(store.LastSelectedProjectId, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NoTemporaryFileStaysBehind()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
await store.RenameAsync(created.ProjectId, "Panel, renamed");
|
||||
await store.SetHappenedAsync(created.ProjectId, DateTimeOffset.UtcNow);
|
||||
await store.RememberSelectionAsync(created.ProjectId);
|
||||
|
||||
Assert.That(Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ANewStoreRemovesTemporaryFilesACrashLeftBehind()
|
||||
{
|
||||
var created = await this.CreateStore().CreateAsync("Panel");
|
||||
var leftOver = $"{this.ProjectPath(created.ProjectId)}.tmp-{Guid.NewGuid():N}";
|
||||
await File.WriteAllTextAsync(leftOver, "{");
|
||||
|
||||
await this.CreateStore().ListProjectsAsync();
|
||||
|
||||
Assert.That(File.Exists(leftOver), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ADamagedPreparationIsListedButNeitherOpenedNorChangedNorDeleted()
|
||||
{
|
||||
const string DAMAGED = "{ \"name\": \"Damaged panel\", \"projectId\": ";
|
||||
var projectId = Guid.NewGuid();
|
||||
var path = await this.WriteProjectFileAsync(projectId, DAMAGED);
|
||||
var store = this.CreateStore();
|
||||
|
||||
var projects = await store.ListProjectsAsync();
|
||||
var loaded = await store.LoadAsync(projectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(projects, Has.Count.EqualTo(1));
|
||||
Assert.That(projects[0].ProjectId, Is.EqualTo(projectId));
|
||||
Assert.That(projects[0].Status, Is.EqualTo(BflProjectLoadStatus.UNAVAILABLE));
|
||||
Assert.That(projects[0].IsAvailable, Is.False);
|
||||
Assert.That(loaded, Is.Null);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.RenameAsync(projectId, "Repaired"));
|
||||
Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo(DAMAGED));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APreparationOfANewerVersionIsListedWithItsNameAndNotWritten()
|
||||
{
|
||||
var projectId = Guid.NewGuid();
|
||||
var json = $$"""
|
||||
{
|
||||
"schemaVersion": {{BflProject.CURRENT_SCHEMA_VERSION + 1}},
|
||||
"projectId": "{{projectId:D}}",
|
||||
"name": "Panel of the future",
|
||||
"createdAtUtc": "2026-11-01T10:00:00+00:00",
|
||||
"modifiedAtUtc": "2026-11-02T10:00:00+00:00",
|
||||
"fieldOfANewerVersion": true
|
||||
}
|
||||
""";
|
||||
|
||||
var path = await this.WriteProjectFileAsync(projectId, json);
|
||||
var store = this.CreateStore();
|
||||
|
||||
var projects = await store.ListProjectsAsync();
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(projects[0].Status, Is.EqualTo(BflProjectLoadStatus.NEWER_VERSION));
|
||||
Assert.That(projects[0].Name, Is.EqualTo("Panel of the future"));
|
||||
Assert.That(projects[0].ModifiedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 11, 2, 10, 0, 0, TimeSpan.Zero)));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SetHappenedAsync(projectId, DateTimeOffset.UtcNow));
|
||||
Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo(json));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APreparationWhoseFileNamesAnotherIdIsNotOpened()
|
||||
{
|
||||
var projectId = Guid.NewGuid();
|
||||
await this.WriteProjectFileAsync(projectId, $$"""{ "schemaVersion": 1, "projectId": "{{Guid.NewGuid():D}}", "name": "Moved" }""");
|
||||
var store = this.CreateStore();
|
||||
|
||||
var projects = await store.ListProjectsAsync();
|
||||
var loaded = await store.LoadAsync(projectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(projects[0].Status, Is.EqualTo(BflProjectLoadStatus.UNAVAILABLE));
|
||||
Assert.That(projects[0].Name, Is.EqualTo("Moved"));
|
||||
Assert.That(loaded, Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APreparationWhichCannotBeOpenedCanStillBeDeleted()
|
||||
{
|
||||
var projectId = Guid.NewGuid();
|
||||
await this.WriteProjectFileAsync(projectId, "not JSON at all");
|
||||
var store = this.CreateStore();
|
||||
|
||||
await store.DeleteAsync(projectId);
|
||||
|
||||
Assert.That(await store.ListProjectsAsync(), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FoldersWhichAreNoPreparationsAreIgnored()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(this.RootDirectory, "promptOverrides"));
|
||||
var created = await this.CreateStore().CreateAsync("Panel");
|
||||
|
||||
var projects = await this.CreateStore().ListProjectsAsync();
|
||||
|
||||
Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { created.ProjectId }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UmlautsStayReadableInTheFile()
|
||||
{
|
||||
var created = await this.CreateStore().CreateAsync("Podiumsdiskussion über Gewässerschutz");
|
||||
|
||||
var json = await File.ReadAllTextAsync(this.ProjectPath(created.ProjectId));
|
||||
|
||||
Assert.That(json, Does.Contain("Podiumsdiskussion über Gewässerschutz"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFormIsStoredInTheFolderOfItsStepAndCountsAsAChange()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var form = new BflStepFormData();
|
||||
form.Set(BflFieldCatalog.FORMAT, "Panel discussion");
|
||||
|
||||
var changed = await store.SaveFormAsync(created.ProjectId, BflStep.FRAMEWORK, form);
|
||||
var loaded = await this.CreateStore().LoadFormAsync(created.ProjectId, BflStep.FRAMEWORK);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(File.Exists(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", "01-framework", "form.json")), Is.True);
|
||||
Assert.That(loaded.Get(BflFieldCatalog.FORMAT), Is.EqualTo("Panel discussion"));
|
||||
Assert.That(changed.ModifiedAtUtc, Is.GreaterThan(created.ModifiedAtUtc));
|
||||
Assert.That((await store.LoadAsync(created.ProjectId))!.ModifiedAtUtc, Is.EqualTo(changed.ModifiedAtUtc));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AStepWithoutAFormGetsAnEmptyOne()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
|
||||
var form = await store.LoadFormAsync(created.ProjectId, BflStep.STRATEGY);
|
||||
var forms = await store.LoadFormsAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(form.Values, Is.Empty);
|
||||
Assert.That(forms.Keys, Is.EquivalentTo(BflStep.All));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AValueOfAFieldThisVersionDoesNotKnowIsKept()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var formPath = await this.WriteFormFileAsync(created.ProjectId, "02-knowledge", """{ "schemaVersion": 1, "values": { "field_of_a_newer_version": "kept" } }""");
|
||||
|
||||
var form = await store.LoadFormAsync(created.ProjectId, BflStep.KNOWLEDGE);
|
||||
form.Set(BflFieldCatalog.CORE_THESES, "Thesis");
|
||||
await store.SaveFormAsync(created.ProjectId, BflStep.KNOWLEDGE, form);
|
||||
|
||||
var json = await File.ReadAllTextAsync(formPath);
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(json, Does.Contain("field_of_a_newer_version"));
|
||||
Assert.That(json, Does.Contain("Thesis"));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ADamagedFormIsNeitherOpenedNorOverwritten()
|
||||
{
|
||||
const string DAMAGED = "{ \"values\": ";
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var formPath = await this.WriteFormFileAsync(created.ProjectId, "01-framework", DAMAGED);
|
||||
|
||||
var forms = await store.LoadFormsAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(forms.ContainsKey(BflStep.FRAMEWORK), Is.False);
|
||||
Assert.That(forms.ContainsKey(BflStep.KNOWLEDGE), Is.True);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.LoadFormAsync(created.ProjectId, BflStep.FRAMEWORK));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveFormAsync(created.ProjectId, BflStep.FRAMEWORK, new BflStepFormData()));
|
||||
Assert.That(await File.ReadAllTextAsync(formPath), Is.EqualTo(DAMAGED));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFormOfANewerVersionIsNotOverwritten()
|
||||
{
|
||||
const string NEWER = """{ "schemaVersion": 2, "values": {} }""";
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var formPath = await this.WriteFormFileAsync(created.ProjectId, "05-strategy", NEWER);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveFormAsync(created.ProjectId, BflStep.STRATEGY, new BflStepFormData()));
|
||||
Assert.That(await File.ReadAllTextAsync(formPath), Is.EqualTo(NEWER));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFormOfAPreparationWhichCannotBeOpenedIsNotWritten()
|
||||
{
|
||||
var projectId = Guid.NewGuid();
|
||||
await this.WriteProjectFileAsync(projectId, "not JSON at all");
|
||||
var store = this.CreateStore();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveFormAsync(projectId, BflStep.FRAMEWORK, new BflStepFormData()));
|
||||
|
||||
Assert.That(Directory.Exists(Path.Combine(this.RootDirectory, projectId.ToString("D"), "steps")), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheStepShownLastIsRememberedWithoutCountingAsAChange()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
|
||||
await store.SetCurrentStepAsync(created.ProjectId, BflStep.STRATEGY);
|
||||
var loaded = await this.CreateStore().LoadAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(created.CurrentStep, Is.EqualTo(BflStep.FRAMEWORK));
|
||||
Assert.That(loaded!.CurrentStep, Is.EqualTo(BflStep.STRATEGY));
|
||||
Assert.That(loaded.ModifiedAtUtc, Is.EqualTo(created.ModifiedAtUtc));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APreparationWithAnUnknownStepIsNotOpened()
|
||||
{
|
||||
var projectId = Guid.NewGuid();
|
||||
await this.WriteProjectFileAsync(projectId, $$"""{ "schemaVersion": 1, "projectId": "{{projectId:D}}", "name": "Panel", "currentStep": 42 }""");
|
||||
|
||||
var loaded = await this.CreateStore().LoadAsync(projectId);
|
||||
|
||||
Assert.That(loaded, Is.Null);
|
||||
}
|
||||
|
||||
private BeyondFactsLabStore CreateStore() => new(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory });
|
||||
|
||||
private async Task<string> WriteFormFileAsync(Guid projectId, string stepFolder, string content)
|
||||
{
|
||||
var path = Path.Combine(this.RootDirectory, projectId.ToString("D"), "steps", stepFolder, "form.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private string ProjectPath(Guid projectId) => Path.Combine(this.RootDirectory, projectId.ToString("D"), "project.json");
|
||||
|
||||
private async Task<string> WriteProjectFileAsync(Guid projectId, string content)
|
||||
{
|
||||
var path = this.ProjectPath(projectId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks who may open the page of the BeyondFacts Lab and what its settings offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The page asks by itself whether it may be opened, because its address can be reached without the tile.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BflAccessTests
|
||||
{
|
||||
[Test]
|
||||
public void ThePageStaysClosedWithoutThePreviewFeature()
|
||||
{
|
||||
Assert.That(BflAccess.CanOpen(CreateSettingsManager()), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ThePageOpensWithThePreviewFeature()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026);
|
||||
|
||||
Assert.That(BflAccess.CanOpen(settingsManager), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ThePageStaysClosedWhenAnOrganizationHidesTheLab()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026);
|
||||
settingsManager.ConfigurationData.App.HiddenAssistants.Add(ConfigurableAssistant.BEYOND_FACTS_LAB_ASSISTANT);
|
||||
|
||||
Assert.That(BflAccess.CanOpen(settingsManager), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSettingsOfferNoConfidenceBelowMedium()
|
||||
{
|
||||
var levels = ConfigurationSelectDataFactory.GetBeyondFactsLabConfidenceLevelsData(CreateSettingsManager()).Select(data => data.Value);
|
||||
|
||||
Assert.That(levels, Is.EqualTo(new[] { ConfidenceLevel.MEDIUM, ConfidenceLevel.HIGH }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEnforcedGlobalMinimumLiftsTheChoice()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.Confidence.EnforceGlobalMinimumConfidence = true;
|
||||
settingsManager.ConfigurationData.Confidence.GlobalMinimumConfidence = ConfidenceLevel.HIGH;
|
||||
|
||||
var levels = ConfigurationSelectDataFactory.GetBeyondFactsLabConfidenceLevelsData(settingsManager).Select(data => data.Value);
|
||||
|
||||
Assert.That(levels, Is.EqualTo(new[] { ConfidenceLevel.HIGH }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSettingsOfferEveryFormOfAddress()
|
||||
{
|
||||
var forms = ConfigurationSelectDataFactory.GetBeyondFactsLabAddressFormData().Select(data => data.Value);
|
||||
|
||||
Assert.That(forms, Is.EquivalentTo(Enum.GetValues<BflAddressForm>()));
|
||||
}
|
||||
|
||||
private static SettingsManager CreateSettingsManager() => new(NullLogger<SettingsManager>.Instance, null!);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the BeyondFacts Lab is registered as a component: who may see it, which providers it accepts,
|
||||
/// and what it shares with the rest of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The component is written out in full, AIStudio.Tools.Components: from this namespace, the short name finds
|
||||
/// the test namespace AIStudio.Tests.Components first.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BflComponentTests
|
||||
{
|
||||
private const AIStudio.Tools.Components LAB = AIStudio.Tools.Components.BEYOND_FACTS_LAB_ASSISTANT;
|
||||
|
||||
[Test]
|
||||
public void TheLabBelongsToItsPreviewFeature()
|
||||
{
|
||||
Assert.That(LAB.RequiredPreviewFeature, Is.EqualTo(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026), "The tile, the page, and the menus all ask the component for its preview feature.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabIsHiddenWithoutItsPreviewFeature()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
|
||||
Assert.That(settingsManager.IsAssistantVisible(LAB, requiredPreviewFeature: LAB.RequiredPreviewFeature), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabIsVisibleWithItsPreviewFeature()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026);
|
||||
|
||||
Assert.That(settingsManager.IsAssistantVisible(LAB, requiredPreviewFeature: LAB.RequiredPreviewFeature), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOrganizationCanHideTheLab()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026);
|
||||
settingsManager.ConfigurationData.App.HiddenAssistants.Add(ConfigurableAssistant.BEYOND_FACTS_LAB_ASSISTANT);
|
||||
|
||||
Assert.That(settingsManager.IsAssistantVisible(LAB, requiredPreviewFeature: LAB.RequiredPreviewFeature), Is.False, "The list of hidden assistants must reach the lab, too.");
|
||||
}
|
||||
|
||||
[TestCase(ConfidenceLevel.NONE)]
|
||||
[TestCase(ConfidenceLevel.LOW)]
|
||||
[TestCase(ConfidenceLevel.MODERATE)]
|
||||
[TestCase(ConfidenceLevel.MEDIUM)]
|
||||
public void TheMinimumConfidenceNeverDropsBelowMedium(ConfidenceLevel configured)
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence = configured;
|
||||
|
||||
Assert.That(LAB.MinimumConfidence(settingsManager), Is.EqualTo(ConfidenceLevel.MEDIUM));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHigherMinimumConfidenceWins()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence = ConfidenceLevel.HIGH;
|
||||
|
||||
Assert.That(LAB.MinimumConfidence(settingsManager), Is.EqualTo(ConfidenceLevel.HIGH));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the lab takes the cloud providers of the region one trusts and refuses all others.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scheme which trusts Europe gives MEDIUM to Mistral and LOW to OpenAI. MEDIUM is the floor of the
|
||||
/// lab, so only the first one may see unpublished research.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void OnlyProvidersOfTheTrustedRegionAreAccepted()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.Confidence.ConfidenceScheme = ConfidenceSchemes.TRUST_EUROPE;
|
||||
var trusted = CreateProvider(LLMProviders.MISTRAL);
|
||||
var untrusted = CreateProvider(LLMProviders.OPEN_AI);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(settingsManager.IsProviderConfident(trusted, LAB), Is.True);
|
||||
Assert.That(settingsManager.IsProviderConfident(untrusted, LAB), Is.False);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AProhibitedProviderIsRefusedAlthoughTrusted()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.Confidence.ConfidenceScheme = ConfidenceSchemes.TRUST_EUROPE;
|
||||
settingsManager.ConfigurationData.App.ProhibitedProviders.Add(LLMProviders.MISTRAL);
|
||||
|
||||
Assert.That(settingsManager.IsProviderConfident(CreateProvider(LLMProviders.MISTRAL), LAB), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabNeitherSendsNorReceivesContent()
|
||||
{
|
||||
Assert.That(LAB.AllowSendTo, Is.False, "Nothing may enter the lab from other places, and its answers stay in its steps.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabKeepsOneSessionPerStep()
|
||||
{
|
||||
Assert.That(LAB.HasSingleSessionSlot, Is.False, "A running step must not block the steps of other preparations.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabWorksWithoutAProfile()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
settingsManager.ConfigurationData.App.PreselectedProfile = Guid.NewGuid().ToString();
|
||||
|
||||
Assert.That(LAB.GetProfilePreselection(settingsManager), Is.EqualTo(ProfilePreselection.FromStoredValue(Profile.NO_PROFILE.Id)), "The lab works with what the person enters per preparation, never with the app-wide profile.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabStartsWithItsPreselectedProvider()
|
||||
{
|
||||
var settingsManager = CreateSettingsManager();
|
||||
var provider = CreateProvider(LLMProviders.MISTRAL);
|
||||
settingsManager.ConfigurationData.Providers.Add(provider);
|
||||
settingsManager.ConfigurationData.BeyondFactsLab.PreselectedProvider = provider.Id;
|
||||
|
||||
Assert.That(LAB.PreselectedProvider(settingsManager).Id, Is.EqualTo(provider.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NewSettingsAddressFormally()
|
||||
{
|
||||
Assert.That(new DataBeyondFactsLab().AddressForm, Is.EqualTo(BflAddressForm.FORMAL), "The texts of AI Studio address the person formally as well.");
|
||||
}
|
||||
|
||||
private static SettingsManager CreateSettingsManager() => new(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
private static AIStudio.Settings.Provider CreateProvider(LLMProviders llmProvider) => new(1, Guid.NewGuid().ToString(), "Test", llmProvider, new("test-model", null));
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the steps and the fields of the forms "Your details" in the BeyondFacts Lab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The forms store their values under the IDs of the fields and options, and the folders of the steps carry their
|
||||
/// names, so all of them are pinned here: changing one would lose what people entered before.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BflFieldCatalogTests
|
||||
{
|
||||
[Test]
|
||||
public void TheStepsKeepTheirNamesAndOrder()
|
||||
{
|
||||
Assert.That(BflStep.All.Select(step => step.ToString()), Is.EqualTo(new[]
|
||||
{
|
||||
"FRAMEWORK", "KNOWLEDGE", "GOALS_AND_ROLE", "VALUES_AND_PERSPECTIVES", "STRATEGY", "TEST_SITUATION", "RESULTS",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFoldersOfTheStepsKeepTheirNames()
|
||||
{
|
||||
Assert.That(BflStep.All.Select(step => step.FolderName), Is.EqualTo(new[]
|
||||
{
|
||||
"01-framework", "02-knowledge", "03-goals", "04-values", "05-strategy", "06-test", "07-results",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFieldsKeepTheirIds()
|
||||
{
|
||||
Assert.That(BflFieldCatalog.FIELDS.Select(field => field.Id), Is.EqualTo(new[]
|
||||
{
|
||||
"format", "event_topic", "audience", "contribution_topic", "event_date", "event_location", "event_duration", "contribution_duration", "other_participants", "event_link",
|
||||
"contribution_summary", "core_theses", "uncertainties", "material_notes",
|
||||
"goals", "goal_directions",
|
||||
"values_and_concerns", "personal_concern", "other_perspectives",
|
||||
"communication_worries",
|
||||
"question_type", "difficulty", "test_notes",
|
||||
"position_changes",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheOptionsKeepTheirIds()
|
||||
{
|
||||
Assert.That(BflFieldCatalog.FIELDS.SelectMany(field => field.Options).Select(option => option.Id), Is.EqualTo(new[]
|
||||
{
|
||||
"goal_inform", "goal_moderate", "goal_take_position", "goal_raise_awareness", "goal_show_options", "goal_influence_policy",
|
||||
"questions_mixed", "questions_scientific_criticism", "questions_emotional_criticism", "questions_manipulative", "questions_political_instrumentalization",
|
||||
"difficulty_moderate", "difficulty_demanding", "difficulty_very_demanding",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryIdIsUnique()
|
||||
{
|
||||
var ids = BflFieldCatalog.FIELDS.Select(field => field.Id).Concat(BflFieldCatalog.FIELDS.SelectMany(field => field.Options).Select(option => option.Id)).ToList();
|
||||
|
||||
Assert.That(ids, Is.Unique);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFieldsOfAStepFollowEachOther()
|
||||
{
|
||||
var stepsInOrder = BflFieldCatalog.FIELDS.Select(field => field.Step).Distinct();
|
||||
|
||||
Assert.That(stepsInOrder, Is.EqualTo(BflStep.All));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryStepHasAFormWithATitleAndADescription()
|
||||
{
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
foreach (var step in BflStep.All)
|
||||
{
|
||||
Assert.That(BflFieldCatalog.ForStep(step), Is.Not.Empty, $"{step} has no fields.");
|
||||
Assert.That(step.Title, Is.Not.Empty, $"{step} has no title.");
|
||||
Assert.That(step.Description, Is.Not.Empty, $"{step} has no description.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyFieldsWithAChoiceHaveOptions()
|
||||
{
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
foreach (var field in BflFieldCatalog.FIELDS)
|
||||
{
|
||||
if (field.Kind.HasOptions)
|
||||
Assert.That(field.Options, Has.Count.GreaterThanOrEqualTo(2), $"{field.Id} offers too few options.");
|
||||
else
|
||||
Assert.That(field.Options, Is.Empty, $"{field.Id} has options, but nothing shows them.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryKindHasALimit()
|
||||
{
|
||||
Assert.That(Enum.GetValues<BflFieldKind>().Select(kind => kind.MaxLength), Has.All.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryFieldAndOptionHasALabel()
|
||||
{
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
foreach (var field in BflFieldCatalog.FIELDS)
|
||||
{
|
||||
Assert.That(field.Label, Is.Not.Empty, $"{field.Id} has no label.");
|
||||
foreach (var option in field.Options)
|
||||
Assert.That(option.Label, Is.Not.Empty, $"{option.Id} has no label.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryFieldAndOptionHasALabelOfItsOwn()
|
||||
{
|
||||
// Equal English texts share one key of the localization, so another language could not tell them apart:
|
||||
var labels = BflFieldCatalog.FIELDS.SelectMany(field => field.Options.Select(option => option.Label).Prepend(field.Label));
|
||||
|
||||
Assert.That(labels, Is.Unique);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyPublicDetailsAboutTheEventReachTheResearch()
|
||||
{
|
||||
var shared = BflFieldCatalog.FIELDS.Where(field => field.IsSharedWithResearch).Select(field => field.Id);
|
||||
|
||||
Assert.That(shared, Is.EquivalentTo(new[]
|
||||
{
|
||||
BflFieldCatalog.FORMAT, BflFieldCatalog.EVENT_TOPIC, BflFieldCatalog.EVENT_DATE, BflFieldCatalog.EVENT_LOCATION, BflFieldCatalog.OTHER_PARTICIPANTS, BflFieldCatalog.EVENT_LINK,
|
||||
}), "The topic of a contribution may be unpublished, and nothing personal belongs on the web.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyThePersonalInvolvementIsPersonal()
|
||||
{
|
||||
var personal = BflFieldCatalog.FIELDS.Where(field => field.IsPersonalDisclosure).Select(field => field.Id);
|
||||
|
||||
Assert.That(personal, Is.EquivalentTo(new[] { BflFieldCatalog.PERSONAL_CONCERN }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyTheOtherParticipantsHoldNames()
|
||||
{
|
||||
var withNames = BflFieldCatalog.FIELDS.Where(field => field.NameRole is not BflNameRole.NONE).Select(field => field.Id);
|
||||
|
||||
Assert.That(withNames, Is.EquivalentTo(new[] { BflFieldCatalog.OTHER_PARTICIPANTS }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheKeyDetailsAreThoseOfTheEventAndTheContribution()
|
||||
{
|
||||
var keyDetails = BflFieldCatalog.FIELDS.Where(field => field.IsKeyDetail).Select(field => field.Id);
|
||||
|
||||
Assert.That(keyDetails, Is.EquivalentTo(new[]
|
||||
{
|
||||
BflFieldCatalog.FORMAT, BflFieldCatalog.EVENT_TOPIC, BflFieldCatalog.AUDIENCE, BflFieldCatalog.CONTRIBUTION_TOPIC, BflFieldCatalog.CONTRIBUTION_SUMMARY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which details of a preparation the BeyondFacts Lab marks as missing, and how a form keeps its values.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflMissingDetailsTests
|
||||
{
|
||||
[Test]
|
||||
public void ANewPreparationLacksTheDetailsOfTheEventAndTheContribution()
|
||||
{
|
||||
var steps = BflMissingDetails.StepsOf(new Dictionary<BflStep, BflStepFormData>());
|
||||
|
||||
Assert.That(steps, Is.EqualTo(new[] { BflStep.FRAMEWORK, BflStep.KNOWLEDGE }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMissingDetailsFollowTheOrderOfTheForm()
|
||||
{
|
||||
var form = new BflStepFormData();
|
||||
form.Set(BflFieldCatalog.EVENT_TOPIC, "Water protection");
|
||||
|
||||
var missing = BflMissingDetails.Of(BflStep.FRAMEWORK, form);
|
||||
|
||||
Assert.That(missing.Select(field => field.Id), Is.EqualTo(new[] { BflFieldCatalog.FORMAT, BflFieldCatalog.AUDIENCE, BflFieldCatalog.CONTRIBUTION_TOPIC }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BlanksCountAsMissing()
|
||||
{
|
||||
var form = new BflStepFormData { Values = { [BflFieldCatalog.CONTRIBUTION_SUMMARY] = " \n " } };
|
||||
|
||||
Assert.That(BflMissingDetails.Of(BflStep.KNOWLEDGE, form), Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFilledFormLacksNothing()
|
||||
{
|
||||
var framework = new BflStepFormData();
|
||||
foreach (var field in BflFieldCatalog.ForStep(BflStep.FRAMEWORK))
|
||||
framework.Set(field.Id, "given");
|
||||
|
||||
var knowledge = new BflStepFormData();
|
||||
knowledge.Set(BflFieldCatalog.CONTRIBUTION_SUMMARY, "A few sentences.");
|
||||
|
||||
var steps = BflMissingDetails.StepsOf(new Dictionary<BflStep, BflStepFormData> { [BflStep.FRAMEWORK] = framework, [BflStep.KNOWLEDGE] = knowledge });
|
||||
|
||||
Assert.That(steps, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEmptyValueRemovesTheField()
|
||||
{
|
||||
var form = new BflStepFormData();
|
||||
form.Set(BflFieldCatalog.GOALS, "Inform the audience");
|
||||
|
||||
form.Set(BflFieldCatalog.GOALS, " ");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(form.Values, Is.Empty);
|
||||
Assert.That(form.Get(BflFieldCatalog.GOALS), Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChoicesAreStoredInTheOrderOfTheOptions()
|
||||
{
|
||||
var field = BflFieldCatalog.FIELDS.Single(candidate => candidate.Id == BflFieldCatalog.GOAL_DIRECTIONS);
|
||||
var form = new BflStepFormData();
|
||||
|
||||
form.SetChoices(field, [BflFieldCatalog.GOAL_INFLUENCE_POLICY, BflFieldCatalog.GOAL_INFORM, "not_an_option"]);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(form.Get(field.Id), Is.EqualTo($"{BflFieldCatalog.GOAL_INFORM},{BflFieldCatalog.GOAL_INFLUENCE_POLICY}"));
|
||||
Assert.That(form.GetChoices(field.Id), Is.EquivalentTo(new[] { BflFieldCatalog.GOAL_INFORM, BflFieldCatalog.GOAL_INFLUENCE_POLICY }));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoChoiceRemovesTheField()
|
||||
{
|
||||
var field = BflFieldCatalog.FIELDS.Single(candidate => candidate.Id == BflFieldCatalog.GOAL_DIRECTIONS);
|
||||
var form = new BflStepFormData();
|
||||
form.SetChoices(field, [BflFieldCatalog.GOAL_INFORM]);
|
||||
|
||||
form.SetChoices(field, []);
|
||||
|
||||
Assert.That(form.Values, Is.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
|
||||
using SharedTools;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the notes the BeyondFacts Lab shows before the first use, and when they show again.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflNoticeTests
|
||||
{
|
||||
[Test]
|
||||
public void TheNotesAreMarkdownWithAHeadingAndParagraphs()
|
||||
{
|
||||
var paragraphs = BflNotice.Text.ReplaceLineEndings("\n").Split("\n\n");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(paragraphs[0], Is.EqualTo("## Welcome!"));
|
||||
Assert.That(paragraphs, Has.Length.EqualTo(5));
|
||||
Assert.That(paragraphs[^1], Does.Contain("prototype"));
|
||||
Assert.That(paragraphs, Has.None.StartsWith(" "), "The indentation of the source must not reach the notes.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheCollectorFindsTheNotesAsTheAppShowsThem()
|
||||
{
|
||||
var source = File.ReadAllText(NoticeSourceFile());
|
||||
var warnings = new List<string>();
|
||||
|
||||
var texts = I18NSourceScanner.FindTexts(source, NoticeSourceFile(), warnings).Select(text => I18NKeyHash.NormalizeLineEndings(text.Text));
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(texts, Does.Contain(I18NKeyHash.NormalizeLineEndings(BflNotice.Text)));
|
||||
Assert.That(warnings, Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChangeOfTheNotesChangesTheHash()
|
||||
{
|
||||
var hash = BflNotice.ComputeHash("## Welcome!\n\nThe notes.");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(BflNotice.ComputeHash("## Welcome!\n\nThe changed notes."), Is.Not.EqualTo(hash));
|
||||
Assert.That(BflNotice.ComputeHash("## Herzlich willkommen!\n\nDie Hinweise."), Is.Not.EqualTo(hash), "Notes in another language were not read yet.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LineEndingsAndOuterSpaceDoNotChangeTheHash()
|
||||
{
|
||||
var hash = BflNotice.ComputeHash("First line\n\nSecond line");
|
||||
|
||||
Assert.That(BflNotice.ComputeHash(" First line\r\n\r\nSecond line\r\n"), Is.EqualTo(hash));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyTheHashOfTheNotesAsTheyAreNowCountsAsConfirmed()
|
||||
{
|
||||
var currentHash = BflNotice.ComputeHash(BflNotice.Text);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(currentHash, Has.Length.EqualTo(64));
|
||||
Assert.That(BflNotice.IsAccepted(currentHash, currentHash), Is.True);
|
||||
Assert.That(BflNotice.IsAccepted(currentHash.ToUpperInvariant(), currentHash), Is.True);
|
||||
Assert.That(BflNotice.IsAccepted(string.Empty, currentHash), Is.False, "Nobody confirmed anything yet.");
|
||||
Assert.That(BflNotice.IsAccepted(BflNotice.ComputeHash("old"), currentHash), Is.False, "The notes changed since.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NoticeSourceFile([CallerFilePath] string sourceFilePath = "") =>
|
||||
Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "MindWork AI Studio", "Assistants", "BeyondFactsLab", "BflNotice.cs"));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the BeyondFacts Lab stays a prototype which nobody sees unless they ask for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The lab grows over a long series of pull requests. Until it is complete, only people who allow
|
||||
/// prototypes and switch the lab on may find it; raising its level is a decision of its own.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BflPreviewFeatureTests
|
||||
{
|
||||
[TestCase(PreviewVisibility.PROTOTYPE)]
|
||||
[TestCase(PreviewVisibility.EXPERIMENTAL)]
|
||||
public void PrototypesOfferTheLab(PreviewVisibility visibility)
|
||||
{
|
||||
Assert.That(visibility.GetPreviewFeatures(), Does.Contain(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026));
|
||||
}
|
||||
|
||||
[TestCase(PreviewVisibility.NONE)]
|
||||
[TestCase(PreviewVisibility.RELEASE_CANDIDATE)]
|
||||
[TestCase(PreviewVisibility.BETA)]
|
||||
[TestCase(PreviewVisibility.ALPHA)]
|
||||
public void LowerLevelsDoNotOfferTheLab(PreviewVisibility visibility)
|
||||
{
|
||||
Assert.That(visibility.GetPreviewFeatures(), Does.Not.Contain(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoweringTheLevelLeavesTheLabOut()
|
||||
{
|
||||
var enabled = new HashSet<PreviewFeatures> { PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026 };
|
||||
|
||||
Assert.That(PreviewVisibility.BETA.FilterPreviewFeatures(enabled), Is.Empty, "A beta level must not keep a prototype switched on.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabIsNotReleased()
|
||||
{
|
||||
Assert.That(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.IsReleased, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabCountsOnlyWhenSwitchedOn()
|
||||
{
|
||||
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
var wasEnabledBefore = PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.IsEnabled(settingsManager);
|
||||
|
||||
settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(wasEnabledBefore, Is.False, "New settings must not switch the lab on.");
|
||||
Assert.That(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.IsEnabled(settingsManager), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLabHasADescription()
|
||||
{
|
||||
Assert.That(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.PreviewDescription, Does.StartWith("BeyondFacts Lab"), "The settings list the preview features by this text.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which preparation the BeyondFacts Lab shows after its list changed.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflProjectSelectionTests
|
||||
{
|
||||
private static readonly BflProjectEntry CHANGED_LAST = Entry("Changed last");
|
||||
private static readonly BflProjectEntry SELECTED_LAST = Entry("Selected last");
|
||||
private static readonly BflProjectEntry JUST_CREATED = Entry("Just created");
|
||||
private static readonly IReadOnlyList<BflProjectEntry> PROJECTS = [CHANGED_LAST, SELECTED_LAST, JUST_CREATED];
|
||||
|
||||
[Test]
|
||||
public void ThePreparationAskedForComesFirst()
|
||||
{
|
||||
var chosen = BflProjectSelection.Choose(PROJECTS, JUST_CREATED.ProjectId, SELECTED_LAST.ProjectId);
|
||||
|
||||
Assert.That(chosen, Is.SameAs(JUST_CREATED));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutAWishThePreparationSelectedLastIsShown()
|
||||
{
|
||||
var chosen = BflProjectSelection.Choose(PROJECTS, null, SELECTED_LAST.ProjectId);
|
||||
|
||||
Assert.That(chosen, Is.SameAs(SELECTED_LAST));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APreparationWhichIsGoneFallsBackToTheOneSelectedLast()
|
||||
{
|
||||
var chosen = BflProjectSelection.Choose(PROJECTS, Guid.NewGuid(), SELECTED_LAST.ProjectId);
|
||||
|
||||
Assert.That(chosen, Is.SameAs(SELECTED_LAST));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutAnyKnownSelectionThePreparationChangedLastIsShown()
|
||||
{
|
||||
var chosen = BflProjectSelection.Choose(PROJECTS, Guid.NewGuid(), Guid.NewGuid());
|
||||
|
||||
Assert.That(chosen, Is.SameAs(CHANGED_LAST));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutPreparationsNothingIsShown()
|
||||
{
|
||||
var chosen = BflProjectSelection.Choose([], Guid.NewGuid(), null);
|
||||
|
||||
Assert.That(chosen, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APreparationWhichCannotBeOpenedCanStillBeShown()
|
||||
{
|
||||
var unavailable = new BflProjectEntry(Guid.NewGuid(), string.Empty, DateTimeOffset.UtcNow, BflProjectLoadStatus.UNAVAILABLE, null);
|
||||
|
||||
var chosen = BflProjectSelection.Choose([CHANGED_LAST, unavailable], unavailable.ProjectId, null);
|
||||
|
||||
Assert.That(chosen, Is.SameAs(unavailable));
|
||||
}
|
||||
|
||||
private static BflProjectEntry Entry(string name)
|
||||
{
|
||||
var project = new BflProject
|
||||
{
|
||||
ProjectId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
ModifiedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
return BflProjectEntry.FromProject(project);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Assistants.SWOTAnalysis;
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
@@ -59,6 +60,11 @@ public static partial class JsonTestRoots
|
||||
// The text around a passage which the provenance store keeps:
|
||||
yield return Case(new(typeof(IReadOnlyList<string>), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS));
|
||||
yield return Case(new(typeof(List<string>), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS));
|
||||
|
||||
// The preparations of the BeyondFacts Lab, their forms, and the preparation selected last:
|
||||
yield return Case(new(typeof(BflProject), BeyondFactsLabJson.PERSISTENCE));
|
||||
yield return Case(new(typeof(BflStepFormData), BeyondFactsLabJson.PERSISTENCE));
|
||||
yield return Case(new(typeof(Guid?), BeyondFactsLabJson.PERSISTENCE), "selected preparation");
|
||||
}
|
||||
|
||||
private static TestCaseData Case(JsonTestRoot root, string? purpose = null)
|
||||
|
||||
@@ -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