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