Polished UI for data sources and batch processing

This commit is contained in:
PaulKoudelka committed 2026-10-20 16:57:09 +02:00
1 parent 7d35536c87
commit 3e4596786a
37 files changed
+2462 -276

No files matched your search

@@ -0,0 +1,201 @@
using AIStudio.Components;
namespace AIStudio.Tests.Components;
public sealed class ConfigurationFileExclusionsTests
{
[Test]
public async Task AParentRefreshDoesNotReplaceAnUncommittedDraft()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
await component.RefreshAsync();
Assert.That(component.Draft, Is.EqualTo("*.new"));
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task AnExternalSettingsChangeReplacesTheDraft()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
component.StoredText = "*.external";
await component.RefreshAsync();
Assert.That(component.Draft, Is.EqualTo("*.external"));
}
finally
{
component.ReleaseResources();
}
}
[TestCase(true, false)]
[TestCase(false, true)]
public async Task DisabledOrLockedFieldsRejectEdits(bool disabled, bool locked)
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.DisableField = disabled;
component.LockField = locked;
component.Edit("*.new");
Assert.That(component.Draft, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[TestCase(true, false)]
[TestCase(false, true)]
public async Task BecomingDisabledOrLockedBeforeAutosaveRejectsThePendingWrite(bool disabled, bool locked)
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
component.DisableField = disabled;
component.LockField = locked;
await component.FlushAsync();
Assert.That(component.StoredText, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task LockingAFieldReplacesItsDraftWithTheStoredSetting()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
component.LockField = true;
await component.RefreshAsync();
Assert.That(component.Draft, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task InvalidRulesAreNotPersisted()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.RejectEdits();
component.Edit(":text:");
await component.FlushAsync();
Assert.That(component.StoredText, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[TestCase(true, false)]
[TestCase(false, true)]
public async Task DisabledOrLockedFieldsRejectExplicitReplacement(bool disabled, bool locked)
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.DisableField = disabled;
component.LockField = locked;
await component.ReplaceAsync("bin/;*.tmp");
Assert.Multiple(() =>
{
Assert.That(component.Draft, Is.EqualTo("*.original"));
Assert.That(component.StoredText, Is.EqualTo("*.original"));
});
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task ExplicitReplacementKeepsAnInvalidOtherGroupsDraftWithoutPersistingIt()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.RejectEdits();
component.Edit("old/;:text:");
await component.ReplaceAsync("bin/;:text:");
Assert.Multiple(() =>
{
Assert.That(component.Draft, Is.EqualTo("bin/;:text:"));
Assert.That(component.StoredText, Is.EqualTo("*.original"));
});
}
finally
{
component.ReleaseResources();
}
}
private sealed class TestableConfigurationFileExclusions : ConfigurationFileExclusions
{
public string StoredText { get; set; } = "*.original";
public bool DisableField { get; set; }
public bool LockField { get; set; }
public string Draft => this.EditingText;
public TestableConfigurationFileExclusions()
{
this.Text = () => this.StoredText;
this.TextUpdate = text => this.StoredText = text;
this.Disabled = () => this.DisableField;
this.IsLocked = () => this.LockField;
}
public void Edit(string text) => this.InternalUpdate(text);
public void RejectEdits() => this.Validation = _ => "Invalid rule";
public Task RefreshAsync() => this.OnParametersSetAsync();
public Task FlushAsync() => this.OptionChanged(this.EditingText);
public Task ReplaceAsync(string text) => this.ApplyTextAsync(text);
// These state-only tests never register with a message bus or render the component.
public void ReleaseResources() => this.DisposeResources();
}
}
@@ -0,0 +1,377 @@
using AIStudio.Components;
using AIStudio.Tools;
namespace AIStudio.Tests.Components;
public sealed class FileExclusionEditorStateTests
{
[TestCase("")]
[TestCase(FileExclusions.DEFAULT_PATTERNS)]
[TestCase(" node_modules/ ; bin/\n*.min.js;draft-* ")]
public void OpeningTheEditorDoesNotRewriteTheStoredText(string text)
{
var state = new FileExclusionEditorState();
state.Receive(text);
Assert.That(state.Text, Is.EqualTo(text));
}
[TestCase("node_modules/;bin/;*.min.js;draft-*")]
[TestCase("node_modules/\nbin/\n*.min.js\ndraft-*")]
[TestCase("node_modules/\r\nbin/\r\n*.min.js\r\ndraft-*")]
[TestCase("node_modules/; bin/\r\n*.min.js; draft-*")]
public void BothGroupsDisplaySemicolonSeparatedPatternsWithoutRewritingStoredLists(string text)
{
var state = Create(text);
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo("node_modules/; bin/"));
Assert.That(state.FileText, Is.EqualTo("*.min.js; draft-*"));
Assert.That(state.Text, Is.EqualTo(text));
});
}
[Test]
public void EditingFoldersKeepsEveryFilePattern()
{
var state = Create("bin/;*.min.js;draft-*");
state.EditFolders("obj/\nbuild/");
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "obj/", "build/", "*.min.js", "draft-*" }));
Assert.That(state.FileText, Is.EqualTo("*.min.js; draft-*"));
Assert.That(state.Text, Is.EqualTo("obj/; build/; *.min.js; draft-*"));
});
}
[Test]
public void EditingFilesKeepsEveryFolderPattern()
{
var state = Create("bin/;node_modules/;*.min.js");
state.EditFiles("*.wav;*.md");
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "bin/", "node_modules/", "*.wav", "*.md" }));
Assert.That(state.FolderText, Is.EqualTo("bin/; node_modules/"));
Assert.That(state.Text, Is.EqualTo("bin/; node_modules/; *.wav; *.md"));
});
}
[TestCase("")]
[TestCase(" ")]
public void ClearingOneGroupKeepsTheOtherGroup(string emptyText)
{
var state = Create("bin/;*.min.js");
state.EditFolders(emptyText);
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "*.min.js" }));
state = Create("bin/;*.min.js");
state.EditFiles(emptyText);
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "bin/" }));
}
[Test]
public void ExplicitFolderRulesKeepTheirExistingMatchingRules()
{
var state = Create(string.Empty);
state.EditFolders("node_modules/;cmake-build-*/");
var patterns = FileExclusions.Parse(state.Text);
Assert.Multiple(() =>
{
Assert.That(patterns, Is.EqualTo(new[] { "node_modules/", "cmake-build-*/" }));
Assert.That(FileExclusions.IsValid(state.Text), Is.True);
Assert.That(state.IsValid, Is.True);
Assert.That(FileExclusions.ExcludesFolder("node_modules", patterns), Is.True);
Assert.That(FileExclusions.ExcludesFolder("cmake-build-debug", patterns), Is.True);
Assert.That(FileExclusions.ExcludesFile("node_modules", patterns), Is.False);
});
}
[TestCase(":text:")]
[TestCase("src/bin/")]
[TestCase("/")]
[TestCase("**/*.md")]
public void EditingTheOtherGroupNeverDropsAnInvalidStoredRule(string invalidRule)
{
var state = Create(invalidRule);
if (invalidRule.EndsWith('/'))
state.EditFiles("*.tmp");
else
state.EditFolders("bin/");
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Does.Contain(invalidRule));
Assert.That(FileExclusions.IsValid(state.Text), Is.False);
});
}
[TestCase(":text:")]
[TestCase(":documents:")]
public void FolderEditingDoesNotTurnAnInvalidSelectorIntoAValidLiteralFolder(string selector)
{
var state = Create(string.Empty);
state.EditFolders(selector);
Assert.Multiple(() =>
{
Assert.That(state.Text, Is.EqualTo(selector));
Assert.That(FileExclusions.IsValid(state.Text), Is.False);
});
}
[Test]
public void ExistingFolderNamesWhichResembleSelectorsArePreservedExactly()
{
var state = Create(":text:/;*.tmp");
state.EditFiles("*.bak");
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { ":text:/", "*.bak" }));
}
[Test]
public void RestoringOneGroupDeduplicatesRulesWithoutLosingOtherRules()
{
var state = Create("custom/;*.TMP;*.tmp;Caddyfile");
state.RestoreFolders("node_modules/;NODE_MODULES/;*.default");
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "node_modules/", "*.TMP", "Caddyfile" }));
}
[TestCase(true)]
[TestCase(false)]
public void RestoringOneFieldPreservesTheOtherFieldsDraft(bool folders)
{
var state = Create("custom/;*.custom");
state.EditFolders("latest/; ");
state.EditFiles("*.latest; ");
const string DEFAULTS = "bin/;node_modules/;*.tmp;*.bak";
if (folders)
state.RestoreFolders(DEFAULTS);
else
state.RestoreFiles(DEFAULTS);
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo(folders ? "bin/; node_modules/" : "latest/; "));
Assert.That(state.FileText, Is.EqualTo(folders ? "*.latest; " : "*.tmp; *.bak"));
Assert.That(state.Text, Is.EqualTo(folders ? "bin/; node_modules/; *.latest" : "latest/; *.tmp; *.bak"));
});
}
[TestCase(true)]
[TestCase(false)]
public void EmptyDefaultsClearOnlyTheRestoredField(bool folders)
{
var state = Create("custom/;*.custom");
if (folders)
state.RestoreFolders(string.Empty);
else
state.RestoreFiles(string.Empty);
Assert.That(state.Text, Is.EqualTo(folders ? "*.custom" : "custom/"));
}
[TestCase(true, ":text:")]
[TestCase(false, "src/bin/")]
public void RestoringOneFieldPreservesInvalidRulesInTheOtherField(bool folders, string invalidRule)
{
var state = Create(invalidRule);
if (folders)
state.RestoreFolders(FileExclusions.DEFAULT_PATTERNS);
else
state.RestoreFiles(FileExclusions.DEFAULT_PATTERNS);
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Does.Contain(invalidRule));
Assert.That(FileExclusions.IsValid(state.Text), Is.False);
});
}
[Test]
public void EditingTheFileFieldDoesNotSilentlyMoveFolderRules()
{
var state = Create("bin/;*.min.js");
state.EditFiles("obj/;*.wav;draft-*");
Assert.Multiple(() =>
{
Assert.That(state.IsValid, Is.False);
Assert.That(state.Text, Is.EqualTo("bin/; obj/; *.wav; draft-*"));
Assert.That(state.FolderText, Is.EqualTo("bin/"));
Assert.That(state.FileText, Is.EqualTo("obj/;*.wav;draft-*"));
});
}
[TestCase(true)]
[TestCase(false)]
public void EditingBothVisibleFieldsPreservesBothDraftsAndAllRules(bool foldersFirst)
{
var state = Create("bin/;*.tmp");
string earlierValue;
if (foldersFirst)
{
state.EditFolders("obj/; build/; ");
earlierValue = state.Text;
state.EditFiles("*.wav; draft-*; ");
}
else
{
state.EditFiles("*.wav; draft-*; ");
earlierValue = state.Text;
state.EditFolders("obj/; build/; ");
}
state.Receive(earlierValue);
state.Receive(state.Text);
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo("obj/; build/; "));
Assert.That(state.FileText, Is.EqualTo("*.wav; draft-*; "));
Assert.That(state.Text, Is.EqualTo("obj/; build/; *.wav; draft-*"));
});
}
[Test]
public void ADebouncedEchoDoesNotOverwriteANewerDraft()
{
var state = Create("*.original");
state.EditFiles("*.first");
var earlierValue = state.Text;
state.EditFiles("*.latest");
state.Receive(earlierValue);
Assert.Multiple(() =>
{
Assert.That(state.Text, Is.EqualTo("*.latest"));
Assert.That(state.FileText, Is.EqualTo("*.latest"));
});
}
[Test]
public void AnUnchangedParentValueDoesNotDiscardAnUncommittedEdit()
{
var state = Create("*.original");
state.EditFiles("*.latest; ");
state.Receive("*.original");
Assert.That(state.FileText, Is.EqualTo("*.latest; "));
}
[Test]
public void ANewExternalValueReplacesBothGroups()
{
var state = Create("bin/;*.tmp");
state.EditFiles("*.latest");
state.Receive("policy/;*.secret");
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo("policy/"));
Assert.That(state.FileText, Is.EqualTo("*.secret"));
});
}
[Test]
public void ADisabledEditorShowsTheActualSettingEvenWhenItMatchesAnOlderDraft()
{
var state = Create("*.original");
state.EditFiles("*.earlier");
state.EditFiles("*.latest");
state.Receive("*.earlier", discardDraft: true);
Assert.That(state.FileText, Is.EqualTo("*.earlier"));
}
[Test]
public void ReformattingTheDefaultsDoesNotChangeTheSelectionSignature()
{
var state = Create(FileExclusions.DEFAULT_PATTERNS);
state.EditFiles(state.FileText);
var before = new FolderFileSelection(
["*"], FileExclusions.Parse(FileExclusions.DEFAULT_PATTERNS), true, false,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
var after = new FolderFileSelection(
["*"], FileExclusions.Parse(state.Text), true, false,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.That(after.Describe(), Is.EqualTo(before.Describe()));
}
[Test]
public void RestoringDefaultsReplacesBothGroupsAndKeepsTheExactDefaultString()
{
var state = Create("custom/;*.custom");
state.EditFolders(":text:");
state.Reset(FileExclusions.DEFAULT_PATTERNS);
Assert.Multiple(() =>
{
Assert.That(state.Text, Is.EqualTo(FileExclusions.DEFAULT_PATTERNS));
Assert.That(FileExclusions.IsValid(state.Text), Is.True);
Assert.That(state.FolderText, Does.Contain("node_modules/"));
Assert.That(state.FileText, Does.Contain("*.exe"));
Assert.That(state.FolderText, Does.Not.Contain("\n").And.Not.Contain("\r"));
Assert.That(state.FileText, Does.Not.Contain("\n").And.Not.Contain("\r"));
});
}
[Test]
public void EditingADefaultGroupPreservesAllRulesInTheOtherGroup()
{
var state = Create(FileExclusions.DEFAULT_PATTERNS);
state.EditFiles(state.FileText + "\n*.scratch");
Assert.That(
FileExclusions.Parse(state.Text),
Is.EquivalentTo(FileExclusions.Parse(FileExclusions.DEFAULT_PATTERNS).Append("*.scratch")));
}
[TestCase("*.pdf")]
[TestCase("Dockerfile")]
[TestCase("node_modules")]
public void WrongFieldDraftsStayVisibleWithoutAddingAFolderSuffix(string pattern)
{
var state = Create("bin/;*.tmp");
state.EditFolders(pattern);
Assert.Multiple(() =>
{
Assert.That(state.IsValid, Is.False);
Assert.That(state.FolderText, Is.EqualTo(pattern));
Assert.That(FileExclusions.Parse(state.Text), Does.Contain(pattern));
Assert.That(FileExclusions.Parse(state.Text), Does.Not.Contain(pattern + "/"));
Assert.That(state.FileText, Is.EqualTo("*.tmp"));
});
}
[Test]
public void FolderRulesInTheFileFieldStayVisibleAndInvalidAfterAParentEcho()
{
var state = Create("bin/;*.tmp");
state.EditFiles("*.bak");
var earlierValue = state.Text;
state.EditFiles("*.bak;obj/");
state.Receive(earlierValue);
Assert.Multiple(() =>
{
Assert.That(state.IsValid, Is.False);
Assert.That(state.FileText, Is.EqualTo("*.bak;obj/"));
Assert.That(state.FolderText, Is.EqualTo("bin/"));
});
}
private static FileExclusionEditorState Create(string text)
{
var state = new FileExclusionEditorState();
state.Receive(text);
return state;
}
}
@@ -0,0 +1,181 @@
using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tests.Components;
public sealed class FileExclusionEditorTests
{
[TestCase(true, "*.pdf")]
[TestCase(true, "Dockerfile")]
[TestCase(true, "node_modules")]
[TestCase(false, "bin/")]
[TestCase(false, "*.pdf;obj/")]
[TestCase(true, ":text:")]
[TestCase(false, ":documents:")]
[TestCase(true, "src/bin/")]
[TestCase(false, "**/*.tmp")]
public async Task WrongFieldAndInvalidRulesAreNotPublished(bool folders, string patterns)
{
var published = new List<string>();
var editor = Create(published);
await editor.EditAsync(patterns, folders);
Assert.That(published, Is.Empty);
}
[Test]
public async Task EditingTheOtherFieldCannotPublishAnInvalidDraft()
{
var published = new List<string>();
var editor = Create(published);
await editor.EditAsync("*.pdf", folders: true);
await editor.EditAsync("*.bak", folders: false);
Assert.That(published, Is.Empty);
await editor.EditAsync("build/", folders: true);
Assert.That(published, Is.EqualTo(new[] { "build/; *.bak" }));
}
[Test]
public async Task MovingARuleToItsCorrectFieldPublishesItEvenIfTheCombinedTextDoesNotChange()
{
var published = new List<string>();
var editor = Create(published, "bin/");
await editor.EditAsync("bin/;*.pdf", folders: true);
await editor.EditAsync("*.pdf", folders: false);
Assert.That(published, Is.Empty);
await editor.EditAsync("bin/", folders: true);
Assert.That(published, Is.EqualTo(new[] { "bin/; *.pdf" }));
}
[TestCase(true, true)]
[TestCase(true, false)]
[TestCase(false, true)]
[TestCase(false, false)]
public async Task ResettingOneFieldCannotPersistInvalidRulesInTheOtherField(bool folders, bool explicitReset)
{
var published = new List<string>();
var editor = Create(published);
var resets = new List<string>();
if (explicitReset)
editor.ResetRequested = EventCallback.Factory.Create<string>(new object(), (string value) => resets.Add(value));
await editor.EditAsync(folders ? "*.keep;wrong/" : "*.wrong", folders: !folders);
await editor.ResetAsync(folders);
Assert.Multiple(() =>
{
Assert.That(published, Is.Empty);
Assert.That(resets, Is.Empty);
});
await editor.EditAsync(folders ? "*.keep" : "keep/", folders: !folders);
Assert.That(published, Is.EqualTo(new[] { folders ? "bin/; *.keep" : "keep/; *.tmp" }));
}
[TestCase(true)]
[TestCase(false)]
public async Task ResettingTheInvalidFieldRestoresAValidValue(bool folders)
{
var published = new List<string>();
var editor = Create(published);
var resets = new List<string>();
editor.ResetRequested = EventCallback.Factory.Create<string>(new object(), (string value) => resets.Add(value));
await editor.EditAsync(folders ? "*.wrong" : "wrong/", folders);
await editor.ResetAsync(folders);
Assert.Multiple(() =>
{
Assert.That(published, Is.Empty);
Assert.That(resets, Is.EqualTo(new[] { folders ? "bin/; *.custom" : "custom/; *.tmp" }));
});
}
[Test]
public async Task DisabledEditorRejectsEditsAndResets()
{
var published = new List<string>();
var editor = Create(published);
editor.Disabled = true;
await editor.EditAsync("changed/", folders: true);
await editor.EditAsync("*.changed", folders: false);
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.That(published, Is.Empty);
}
[Test]
public async Task ReadOnlyEditorRejectsEditsAndBothResetCallbacks()
{
var published = new List<string>();
var resets = new List<string>();
var editor = Create(published);
editor.ReadOnly = true;
editor.ResetRequested = EventCallback.Factory.Create<string>(new object(), (string value) => resets.Add(value));
editor.LoadParameters();
await editor.EditAsync("changed/", folders: true);
await editor.EditAsync("*.changed", folders: false);
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.Multiple(() =>
{
Assert.That(published, Is.Empty);
Assert.That(resets, Is.Empty);
});
editor.ResetRequested = default;
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.That(published, Is.Empty);
// Returning to editing must reveal the unchanged source, not either attempted edit.
editor.ReadOnly = false;
editor.LoadParameters();
await editor.EditAsync("*.other", folders: false);
Assert.That(published, Is.EqualTo(new[] { "custom/; *.other" }));
}
[Test]
public async Task HiddenResetButtonsCannotChangeTheRules()
{
var published = new List<string>();
var editor = Create(published);
editor.ShowReset = false;
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.That(published, Is.Empty);
}
private static TestableFileExclusionEditor Create(List<string> published, string initial = "custom/;*.custom")
{
var editor = new TestableFileExclusionEditor
{
Text = initial,
ResetValue = "bin/;*.tmp",
TextChanged = EventCallback.Factory.Create<string>(new object(), (string value) => published.Add(value)),
};
editor.LoadParameters();
return editor;
}
private sealed class TestableFileExclusionEditor : FileExclusionEditor
{
public void LoadParameters() => this.OnParametersSet();
public Task EditAsync(string patterns, bool folders) => folders ? this.ChangeFoldersAsync(patterns) : this.ChangeFilesAsync(patterns);
public Task ResetAsync(bool folders) => this.RestoreDefaultsAsync(folders);
}
}
@@ -0,0 +1,84 @@
using AIStudio.Components;
using AIStudio.Tools;
namespace AIStudio.Tests.Components;
public sealed class FilePatternSelectionTests
{
[TestCase("")]
[TestCase("*.mp3;draft-*")]
[TestCase(FileExclusions.DEFAULT_PATTERNS)]
public void ValidExclusionsArePermanentlyVisible(string patterns)
{
var component = new TestableFilePatternSelection();
component.SetExclusions(patterns);
Assert.That(component.HasExclusions, Is.True);
}
[Test]
public void AParentRefreshKeepsTheExistingExclusionFieldVisible()
{
var component = new TestableFilePatternSelection();
component.SetExclusions("*.mp3;draft-*");
var field = component.ExcludedContent;
component.RefreshParameters();
Assert.Multiple(() =>
{
Assert.That(component.HasExclusions, Is.True);
Assert.That(component.ExcludedContent, Is.SameAs(field), "A parent render must keep the existing field and its validation.");
});
}
[TestCase(":text:")]
[TestCase("src/bin/")]
[TestCase("**/*.txt")]
public void InvalidStoredExclusionsRemainVisible(string patterns)
{
var component = new TestableFilePatternSelection();
component.SetExclusions(patterns);
Assert.That(component.HasExclusions, Is.True);
}
[Test]
public void InclusionOnlyCallersHaveNoExclusionSection()
{
var component = new TestableFilePatternSelection();
component.RefreshParameters();
Assert.Multiple(() =>
{
Assert.That(component.ExcludedContent, Is.Null);
Assert.That(component.HasExclusions, Is.False);
});
}
[Test]
public void RemovingTheOptionalFieldRemovesTheExclusionSection()
{
var component = new TestableFilePatternSelection();
component.SetExclusions("*.mp3");
component.RemoveExclusions();
Assert.That(component.HasExclusions, Is.False);
}
private sealed class TestableFilePatternSelection : FilePatternSelection
{
public void SetExclusions(string patterns)
{
this.ExcludedContent = builder => builder.AddContent(0, patterns);
this.OnParametersSet();
}
public void RemoveExclusions()
{
this.ExcludedContent = null;
this.OnParametersSet();
}
public void RefreshParameters() => this.OnParametersSet();
}
}
+13 -1
View File
@@ -40,6 +40,18 @@ public sealed class EmbeddingSignatureTests
"Patterns change which files belong to the source, not how retained files are embedded; the indexing run removes and adds only the affected files.");
}
[Test]
public void ChangingDirectoryExclusionsKeepsTheStoredEmbeddingsOfRetainedFiles()
{
var allFiles = DataSource(ConfidenceLevel.LOW);
var filteredFiles = allFiles with { ExcludePatterns = "*.mp3;draft-*" };
Assert.That(
Signature(filteredFiles),
Is.EqualTo(Signature(allFiles)),
"Exclusions change which files belong to the source, not how retained files are embedded.");
}
[Test]
public void ChangingDirectoryRecursionKeepsTheStoredEmbeddingsOfRetainedFiles()
{
@@ -244,4 +256,4 @@ public sealed class EmbeddingSignatureTests
public int ChunkOverlapTokenLength { get; init; }
}
}
}
+65
View File
@@ -36,6 +36,71 @@ public sealed class FileExclusionsTests
Assert.That(new DataSourceValidation().ValidateExcludePatterns(patterns), Is.Not.Null);
}
[TestCase(null, true)]
[TestCase("", true)]
[TestCase(" ; \r\n", true)]
[TestCase("build/;cmake-build-*/", true)]
[TestCase(".env/;archive.v1/;*.vmwarevm/", true)]
[TestCase("*.pdf", false)]
[TestCase("Dockerfile", false)]
[TestCase("node_modules", false)]
[TestCase("build/;*.txt", false)]
public void FolderFieldRequiresExplicitFolderRules(string? patterns, bool valid)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValidFolders(patterns), Is.EqualTo(valid));
Assert.That(FileExclusions.ValidateFolders(patterns) is null, Is.EqualTo(valid));
});
}
[TestCase(null, true)]
[TestCase("", true)]
[TestCase(" ; \r\n", true)]
[TestCase("*.pdf;draft-*;report-?.md", true)]
[TestCase("Dockerfile;Caddyfile;.gitignore", true)]
[TestCase("build", true)]
[TestCase("build/", false)]
[TestCase("*.tmp;obj/", false)]
public void FileFieldRejectsFolderRulesButAcceptsExtensionlessFiles(string? patterns, bool valid)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValidFiles(patterns), Is.EqualTo(valid));
Assert.That(FileExclusions.ValidateFiles(patterns) is null, Is.EqualTo(valid));
});
}
[TestCase(":text:")]
[TestCase(":documents:")]
[TestCase("src/build/")]
[TestCase("**/*.tmp")]
[TestCase("/")]
[TestCase("./")]
public void BothFieldValidatorsReuseTheExistingSyntaxErrors(string patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValidFolders(patterns), Is.False);
Assert.That(FileExclusions.IsValidFiles(patterns), Is.False);
Assert.That(FileExclusions.ValidateFolders(patterns), Is.EqualTo(FileExclusions.Validate(patterns)));
Assert.That(FileExclusions.ValidateFiles(patterns), Is.EqualTo(FileExclusions.Validate(patterns)));
});
}
[Test]
public void StoredListsStillAcceptBothKindsOfRulesTogether()
{
const string PATTERNS = "bin/;*.pdf;Dockerfile;.git/";
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValid(PATTERNS), Is.True);
Assert.That(FileExclusions.Validate(PATTERNS), Is.Null);
Assert.That(FileExclusions.ExcludesFolder("bin", FileExclusions.Parse(PATTERNS)), Is.True);
Assert.That(FileExclusions.ExcludesFile("report.pdf", FileExclusions.Parse(PATTERNS)), Is.True);
});
}
[Test]
public void EntriesAreSeparatedBySemicolonsAndLineBreaks()
{
@@ -0,0 +1,153 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
public sealed class FilePatternExclusionTests
{
[TestCase("*", "*.mp3;*.webm", "recording.WEBM", false)]
[TestCase("*", "*.mp3;*.webm", "report.pdf", true)]
[TestCase("*.txt;*.pdf", "draft-*", "draft-report.pdf", false)]
[TestCase("*.txt;*.pdf", "draft-*", "report.pdf", true)]
[TestCase("*.txt", "*.mp3", "report.pdf", false)]
[TestCase("*", "Dockerfile;Caddyfile", "Dockerfile", false)]
[TestCase("*", ".env", ".env", false)]
[TestCase("*", "*", "report.pdf", false)]
[TestCase("*", "", "report.pdf", true)]
[TestCase("*", "*-draft-*", "report-draft-final.md", false)]
[TestCase("*", "draft-*", "draft-", false)]
[TestCase("*", "report-?.md", "report-a.md", false)]
[TestCase("*", "report-?.md", "report-ab.md", true)]
public void ExclusionsOverrideIncludesButNeverSelectOtherFiles(string include, string exclude, string fileName, bool expected)
{
var path = Path.Combine(Path.GetTempPath(), "nested", fileName);
var selection = new FolderFileSelection(
FileNamePatterns.Parse(include),
FileExclusions.Parse(exclude),
false,
false,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.That(selection.Selects(path), Is.EqualTo(expected));
}
[TestCase("build", true)]
[TestCase("app-build", true)]
[TestCase("my-build", true)]
[TestCase("BUILD", true)]
[TestCase("builder", false)]
[TestCase("build-output", false)]
public void TheFolderExampleMatchesNamesEndingInBuildButNotFiles(string name, bool expected)
{
var patterns = FileExclusions.Parse("*build/");
var path = Path.Combine(Path.GetTempPath(), "test-data", name);
Assert.Multiple(() =>
{
Assert.That(FileExclusions.ExcludesFolder(path, patterns), Is.EqualTo(expected));
Assert.That(FileExclusions.ExcludesFile(path, patterns), Is.False);
});
}
[TestCase(false, "", true, false)]
[TestCase(true, "", true, true)]
[TestCase(false, ".gitignore", false, false)]
[TestCase(true, ".gitignore", false, false)]
[TestCase(false, ".hidden/", true, false)]
[TestCase(true, ".hidden/", true, false)]
public async Task ExplicitDotPatternsBypassOnlyTheHiddenFileRuleNotExclusionsOrHiddenFolders(
bool includeHidden, string exclusions, bool selectsRootFile, bool selectsNestedFile)
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-dot-pattern-priority-{Guid.NewGuid():N}");
var hiddenDirectory = Path.Combine(directoryPath, ".hidden");
var rootFile = Path.Combine(directoryPath, ".gitignore");
var nestedFile = Path.Combine(hiddenDirectory, ".gitignore");
Directory.CreateDirectory(hiddenDirectory);
try
{
await File.WriteAllTextAsync(rootFile, "bin/");
await File.WriteAllTextAsync(nestedFile, "obj/");
var selection = new FolderFileSelection(
[".gitignore"], FileExclusions.Parse(exclusions), true, includeHidden,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
var selectedFiles = selection.EnumerateCandidates(directoryPath).Where(selection.Selects).ToArray();
Assert.Multiple(() =>
{
Assert.That(selectedFiles.Contains(rootFile), Is.EqualTo(selectsRootFile));
Assert.That(selectedFiles.Contains(nestedFile), Is.EqualTo(selectsNestedFile));
Assert.That(selection.LeavesOut(directoryPath, rootFile), Is.EqualTo(!selectsRootFile));
Assert.That(selection.LeavesOut(directoryPath, nestedFile), Is.EqualTo(!selectsNestedFile));
});
}
finally
{
Directory.Delete(directoryPath, true);
}
}
[Test]
public void EnvironmentDefaultsExcludeVisibleEnvironmentFoldersRatherThanDotEnvFiles()
{
var defaults = FileExclusions.Parse(FileExclusions.DEFAULT_PATTERNS);
Assert.Multiple(() =>
{
Assert.That(FileExclusions.ExcludesFolder("env", defaults), Is.True);
Assert.That(FileExclusions.ExcludesFolder("venv", defaults), Is.True);
Assert.That(FileExclusions.ExcludesFile(".env", defaults), Is.False);
Assert.That(FileExclusions.ExcludesFolder(".env", defaults), Is.False,
"Hidden folders are handled by the hidden-file switch, not by the default exclusion list.");
});
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void ExclusionsMayBeEmptyWhileIncludesAreRequired(string? patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Validate(patterns), Is.Null);
Assert.That(FileExclusions.IsValid(patterns), Is.True);
Assert.That(FileNamePatterns.IsValid(patterns), Is.False);
});
}
[TestCase("**/*.txt")]
[TestCase("folder/*.txt")]
public void OptionalExclusionsStillRejectMalformedRules(string patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Validate(patterns), Is.Not.Null);
Assert.That(FileExclusions.IsValid(patterns), Is.False);
});
}
[Test]
public async Task TextSelectorsCanBeCombinedWithFilenameExclusions()
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-exclusions-{Guid.NewGuid():N}");
Directory.CreateDirectory(directoryPath);
try
{
var terraform = Path.Combine(directoryPath, "main.tf");
var caddy = Path.Combine(directoryPath, "Caddyfile");
await File.WriteAllTextAsync(terraform, "resource example {}");
await File.WriteAllTextAsync(caddy, "localhost { respond hello }");
var selection = new FolderFileSelection(
[":text:"], ["*.tf"], false, false, FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.Multiple(() =>
{
Assert.That(selection.Selects(terraform), Is.False);
Assert.That(selection.Selects(caddy), Is.True);
});
}
finally
{
Directory.Delete(directoryPath, true);
}
}
}
@@ -262,6 +262,32 @@ public sealed class FileSourceIndexerWatcherTests
Assert.That(FileSourceIndexer.IsSameWatchConfiguration(before, after), Is.False);
}
[Test]
public void ReorderingEquivalentExclusionsDoesNotRecreateTheWatcher()
{
var before = DefaultConfiguration(Path.GetTempPath()) with
{
ExcludePatterns = FileExclusions.Parse("*.mp3;draft-*"),
};
var after = before with { ExcludePatterns = FileExclusions.Parse("DRAFT-*;*.MP3;*.mp3") };
Assert.That(FileSourceIndexer.IsSameWatchConfiguration(before, after), Is.True);
}
[Test]
public void RenamingAnIncludedFileIntoAnExcludedNameStillRefreshesTheIndex()
{
var rootPath = Path.Combine(Path.GetTempPath(), $"ai-studio-excluded-rename-{Guid.NewGuid():N}");
var configuration = DefaultConfiguration(rootPath) with
{
FilePatterns = FileNamePatterns.Parse("*.md"),
ExcludePatterns = FileExclusions.Parse("draft-*"),
};
var renamed = new RenamedEventArgs(WatcherChangeTypes.Renamed, rootPath, "draft-report.md", "report.md");
Assert.That(FileSourceIndexer.IsRelevantWatcherEvent(configuration, renamed), Is.True);
}
[Test]
public async Task DirectoryWatcherIgnoresTheBatchTranscriptWhenTheMediaIsSelected()
{
@@ -39,7 +39,8 @@ public sealed class LocalDirectoryFilePatternsTests
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("*.txt;")]
[TestCase(";")]
[TestCase(" ; ; ")]
[TestCase("folder/*.txt")]
public void InvalidStoredPatternsFallBackWithoutRemovingExistingDocuments(string? storedPatterns)
{
@@ -48,12 +49,60 @@ public sealed class LocalDirectoryFilePatternsTests
Assert.That(dataSource.EffectiveFilePatterns, Is.EqualTo(DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS));
}
[Test]
public void BatchExclusionsSurviveSettingsSerialization()
{
var written = new DataBatchProcessing { ExcludePatterns = "*.mp3;*.webm;draft-*" };
var json = JsonSerializer.Serialize(written, SettingsManager.JSON_OPTIONS);
var read = JsonSerializer.Deserialize<DataBatchProcessing>(json, SettingsManager.JSON_OPTIONS);
Assert.That(read!.ExcludePatterns, Is.EqualTo(written.ExcludePatterns));
}
[Test]
public void PatternsAreSplitAndTrimmed()
{
Assert.That(FileNamePatterns.Parse(" *.txt ; *.tf ; .gitignore "), Is.EqualTo(new[] { "*.txt", "*.tf", ".gitignore" }));
}
[TestCase("*.txt;")]
[TestCase(";*.txt")]
[TestCase(";; *.txt ; ; ")]
public void EmptyEntriesAreIgnoredWithoutBroadeningTheSelection(string patterns)
{
var parsed = FileNamePatterns.Parse(patterns);
var dataSource = new DataSourceLocalDirectory { FilePatterns = patterns };
var selection = new FolderFileSelection(parsed, [], false, false, FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
var canonical = new FolderFileSelection(["*.txt"], [], false, false, FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.Multiple(() =>
{
Assert.That(FileNamePatterns.IsValid(patterns), Is.True);
Assert.That(FileNamePatterns.Validate(patterns), Is.Null);
Assert.That(parsed, Is.EqualTo(new[] { "*.txt" }));
Assert.That(dataSource.EffectiveFilePatterns, Is.EqualTo(patterns), "Empty entries must not trigger the default document selection.");
Assert.That(selection.Selects("report.txt"), Is.True);
Assert.That(selection.Selects("report.md"), Is.False);
Assert.That(selection.Selects("recording.wav"), Is.False);
Assert.That(selection.Describe(), Is.EqualTo(canonical.Describe()), "Empty entries must not change the indexing selection signature.");
});
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase(";")]
[TestCase(" ; ;; ")]
public void IncludesStillRequireAtLeastOneNonEmptyPattern(string? patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileNamePatterns.Parse(patterns), Is.Empty);
Assert.That(FileNamePatterns.IsValid(patterns), Is.False);
Assert.That(FileNamePatterns.Validate(patterns), Is.Not.Null);
});
}
[Test]
public void NormalizingPatternsRemovesCaseInsensitiveDuplicates()
{
@@ -75,6 +124,7 @@ public sealed class LocalDirectoryFilePatternsTests
{
Type = DataSourceType.LOCAL_DIRECTORY,
FilePatterns = "*.txt;*.tf;.gitignore",
ExcludePatterns = "secret-*;*.backup",
IncludeSubdirectories = false,
IncludeHiddenFilesAndFolders = true,
};
@@ -85,8 +135,10 @@ public sealed class LocalDirectoryFilePatternsTests
Assert.Multiple(() =>
{
Assert.That(json, Does.Not.Contain(nameof(DataSourceLocalDirectory.EffectiveFilePatterns)));
Assert.That(json, Does.Not.Contain(nameof(DataSourceLocalDirectory.EffectiveExcludePatterns)));
Assert.That(read, Is.TypeOf<DataSourceLocalDirectory>());
Assert.That(((DataSourceLocalDirectory)read!).FilePatterns, Is.EqualTo("*.txt;*.tf;.gitignore"));
Assert.That(((DataSourceLocalDirectory)read).ExcludePatterns, Is.EqualTo("secret-*;*.backup"));
Assert.That(((DataSourceLocalDirectory)read).IncludeSubdirectories, Is.False);
Assert.That(((DataSourceLocalDirectory)read).IncludeHiddenFilesAndFolders, Is.True);
});
@@ -419,17 +471,22 @@ public sealed class LocalDirectoryFilePatternsTests
[TestCase(":documents:")]
[TestCase(":DOCUMENTS:;*.tf")]
[TestCase("report-??.md")]
[TestCase("*.txt;")]
[TestCase(";*.txt;; *.tf ; ;")]
[TestCase(":documents:;;")]
public void DataSourceValidationAcceptsSimplePatterns(string patterns)
{
Assert.That(new DataSourceValidation().ValidateFilePatterns(patterns), Is.Null);
}
[TestCase("")]
[TestCase("*.txt;")]
[TestCase(" ; ;; ")]
[TestCase("**/*.txt")]
[TestCase("folder/*.txt")]
[TestCase(":document:")]
[TestCase("*.txt;:docs:")]
[TestCase(";*.txt;; :docs: ;")]
[TestCase("*.txt;;folder/*.md;")]
public void DataSourceValidationRejectsInvalidPatterns(string patterns)
{
Assert.That(new DataSourceValidation().ValidateFilePatterns(patterns), Is.Not.Null);
@@ -0,0 +1,31 @@
using AIStudio.Components;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
public sealed class ManagePandocDependencyTests
{
[Test]
public void TheInstallationPromptStaysHiddenWhileAvailabilityIsChecked()
{
Assert.That(ManagePandocDependency.ShouldShowInstallationPrompt(null), Is.False);
}
[TestCase(true)]
[TestCase(false)]
public void AnAvailableInstallationNeedsNoPrompt(bool isLocal)
{
var installation = new PandocInstallation(true, string.Empty, true, "3.7.0.2", isLocal);
Assert.That(ManagePandocDependency.ShouldShowInstallationPrompt(installation), Is.False);
}
[TestCase(true)]
[TestCase(false)]
public void AnUnavailableOrOutdatedInstallationShowsThePrompt(bool checkWasSuccessful)
{
var installation = new PandocInstallation(checkWasSuccessful, "Unavailable", false, string.Empty, false);
Assert.That(ManagePandocDependency.ShouldShowInstallationPrompt(installation), Is.True);
}
}
+192
View File
@@ -0,0 +1,192 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
using AIStudio.Tools.Services.Indexing;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks combinations of media, transcripts, and ordinary Markdown against the current folder
/// selection rules. Transcript reuse exercises the shared implementation used by RAG and batch.
/// </summary>
public sealed class MediaFileSelectionTests
{
private const string TRANSCRIPT_TEXT = "# Transcript\nSpoken content from the recording.";
private string rootPath = string.Empty;
private string userFolder = string.Empty;
[SetUp]
public void CreateDirectory()
{
this.rootPath = Path.Combine(Path.GetTempPath(), $"ai-studio-media-selection-{Guid.NewGuid():N}");
this.userFolder = Path.Combine(this.rootPath, "user");
Directory.CreateDirectory(this.userFolder);
}
[TearDown]
public void DeleteDirectory() => Directory.Delete(this.rootPath, true);
[TestCase("*.wav", "*.md")]
[TestCase("*.wav;*.md", "*.md")]
[TestCase("*", "*.md")]
public async Task ExcludingMarkdownStillSelectsTheMedia(string includes, string excludes)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
Assert.That(this.Discover(includes, excludes), Is.EqualTo(new[] { mediaPath }));
}
[TestCase("*.md")]
[TestCase("*.wav;*.md")]
[TestCase("*")]
public async Task ExcludingMediaKeepsOrdinaryMarkdownAndTheIndependentTranscript(string includes)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
Assert.That(this.Discover(includes, "*.wav"), Is.EquivalentTo(new[]
{
MediaSidecarTranscripts.GetFilePath(mediaPath),
Path.Combine(this.userFolder, "notes.md"),
Path.Combine(this.userFolder, "readme.md"),
}));
}
[Test]
public async Task SelectingMediaAndMarkdownDoesNotProcessTheTranscriptTwice()
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
Assert.That(this.Discover("*.wav;*.md", string.Empty), Is.EquivalentTo(new[]
{
mediaPath,
Path.Combine(this.userFolder, "notes.md"),
Path.Combine(this.userFolder, "readme.md"),
}));
}
[TestCase(false)]
[TestCase(true)]
public async Task ExistingTranscriptsAreReusedWithoutAProviderOrARewrite(bool dataSourceTranscript)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
IMediaTranscriptLocation location = dataSourceTranscript
? this.TranscriptStore()
: MediaSidecarTranscripts.INSTANCE;
var slot = await location.ResolveAsync(mediaPath, CancellationToken.None);
Directory.CreateDirectory(Path.GetDirectoryName(slot.Path)!);
await File.WriteAllTextAsync(slot.Path, TRANSCRIPT_TEXT);
var originalWriteTime = File.GetLastWriteTimeUtc(slot.Path);
var calls = 0;
Task<MediaTranscriptionResult> Transcribe(string _, TranscriptionProvider __, CancellationToken ___)
{
calls++;
throw new InvalidOperationException("An existing transcript must not ask a provider.");
}
for (var attempt = 0; attempt < 2; attempt++)
{
var result = await PersistentMediaTranscript.ReadOrCreateAsync(
location, mediaPath, null, Transcribe, NullLogger.Instance, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(result.Reused, Is.True);
Assert.That(File.ReadAllText(result.TranscriptPath), Is.EqualTo(TRANSCRIPT_TEXT));
Assert.That(File.GetLastWriteTimeUtc(slot.Path), Is.EqualTo(originalWriteTime));
Assert.That(calls, Is.Zero);
});
}
}
[Test]
public async Task ChangedMediaDoesNotReuseAnOldDataSourceTranscriptEvenWithTheSameSizeAndWriteTime()
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
var store = this.TranscriptStore();
var originalSlot = await store.ResolveAsync(mediaPath, CancellationToken.None);
Directory.CreateDirectory(Path.GetDirectoryName(originalSlot.Path)!);
await File.WriteAllTextAsync(originalSlot.Path, TRANSCRIPT_TEXT);
var originalWriteTime = File.GetLastWriteTimeUtc(mediaPath);
await File.WriteAllBytesAsync(mediaPath, [4, 5, 6]);
File.SetLastWriteTimeUtc(mediaPath, originalWriteTime);
var changedSlot = await store.ResolveAsync(mediaPath, CancellationToken.None);
var result = await PersistentMediaTranscript.ReadOrCreateAsync(
store, mediaPath, null,
(_, _, _) => throw new InvalidOperationException("No provider was configured."),
NullLogger.Instance, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(changedSlot.Key, Is.Not.EqualTo(originalSlot.Key));
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.NO_PROVIDER));
Assert.That(File.ReadAllText(originalSlot.Path), Is.EqualTo(TRANSCRIPT_TEXT));
});
}
[TestCase("*.wav", "*.md", false, false)]
[TestCase("*.md", "*.wav", true, true)]
[TestCase("*.wav;*.md", "", true, false)]
public async Task WatcherUsesTheSelectionOfTheMediaWhenDecidingAboutItsSidecar(
string includes, string excludes, bool markdownChangesMatter, bool transcriptChangesMatter)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
var configuration = new FileSourceIndexer.DataSourceWatcherConfiguration(
this.userFolder,
false,
FilePatterns: FileNamePatterns.Parse(includes),
ExcludePatterns: FileExclusions.Parse(excludes));
Assert.Multiple(() =>
{
Assert.That(
FileSourceIndexer.IsRelevantWatcherPath(configuration, Path.Combine(this.userFolder, "notes.md"), WatcherChangeTypes.Changed),
Is.EqualTo(markdownChangesMatter));
Assert.That(
FileSourceIndexer.IsRelevantWatcherPath(configuration, MediaSidecarTranscripts.GetFilePath(mediaPath), WatcherChangeTypes.Changed),
Is.EqualTo(transcriptChangesMatter));
});
}
private async Task<string> CreateMediaAndDocumentsAsync()
{
var mediaPath = Path.Combine(this.userFolder, "test123.wav");
// These bytes cannot be decoded: reuse must find the transcript before asking a provider.
await File.WriteAllBytesAsync(mediaPath, [1, 2, 3]);
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(mediaPath), TRANSCRIPT_TEXT);
await File.WriteAllTextAsync(Path.Combine(this.userFolder, "notes.md"), "# Ordinary notes");
await File.WriteAllTextAsync(Path.Combine(this.userFolder, "readme.md"), "# Ordinary readme");
return mediaPath;
}
private IReadOnlyList<string> Discover(string includes, string excludes)
{
using var indexer = new FileSourceIndexer(null!, null!, null!, null!, NullLogger.Instance);
var result = indexer.GetInputFiles(new DataSourceLocalDirectory
{
Type = DataSourceType.LOCAL_DIRECTORY,
Path = this.userFolder,
FilePatterns = includes,
ExcludePatterns = excludes,
IncludeSubdirectories = false,
}, new DataSourceEmbeddingManifest(),
MediaTranscriptionAvailability.Allowed(new TranscriptionProvider { Id = "test-transcription-provider" }));
Assert.Multiple(() =>
{
Assert.That(result.Failures, Is.Empty);
Assert.That(result.IgnoredFiles, Is.Empty);
});
return result.Files.Select(file => file.FullName).ToArray();
}
private DataSourceTranscriptStore TranscriptStore() => new(
Path.Combine(this.rootPath, "data"), "d401ce54-9a42-4e78-85de-2402e07f7f7e");
}