using TinyTUI.Autocomplete; namespace TinyTUI.Tests.Autocomplete; public sealed class AutocompleteProviderTests : IDisposable { private readonly string _basePath = Path.Combine(Path.GetTempPath(), "ttui-autocomplete-" + Guid.NewGuid().ToString("N")); public AutocompleteProviderTests() { Directory.CreateDirectory(_basePath); } [Fact] public async Task FilePathProviderExtractsForcedPathPrefixes() { WriteFile("alpha.txt"); var provider = new FilePathAutocompleteProvider(_basePath); var dotSlash = await GetSuggestions(provider, "./a", force: true); var parent = await GetSuggestions(provider, "../", force: true); var slash = await GetSuggestions(provider, "/", force: true); Assert.Equal("./a", dotSlash?.Prefix); Assert.Equal("../", parent?.Prefix); Assert.Equal("/", slash?.Prefix); } [Fact] public async Task FilePathProviderReturnsDirectoriesFirstThenFiles() { Directory.CreateDirectory(Path.Combine(_basePath, "src")); WriteFile("src.txt"); WriteFile("alpha.txt"); var provider = new FilePathAutocompleteProvider(_basePath); var result = await GetSuggestions(provider, "s", force: true); Assert.NotNull(result); var values = result.Items.Select(static item => item.Value).ToArray(); Assert.Equal(["src/", "src.txt"], values); Assert.Equal("src/", result.Items[0].Label); } [Fact] public async Task FilePathProviderPreservesPathPrefixForNestedCompletion() { Directory.CreateDirectory(Path.Combine(_basePath, "src")); WriteFile("src/index.cs"); WriteFile("src/input.cs"); var provider = new FilePathAutocompleteProvider(_basePath); var result = await GetSuggestions(provider, "./src/i", force: true); Assert.NotNull(result); var values = result.Items.Select(static item => item.Value).ToArray(); Assert.Equal("./src/i", result.Prefix); Assert.Equal(["./src/index.cs", "./src/input.cs"], values); } [Fact] public async Task FilePathProviderCompletesAttachmentPrefix() { Directory.CreateDirectory(Path.Combine(_basePath, "src")); WriteFile("src.txt"); var provider = new FilePathAutocompleteProvider(_basePath); var result = await GetSuggestions(provider, "@src"); Assert.NotNull(result); var values = result.Items.Select(static item => item.Value).ToArray(); Assert.Equal("@src", result.Prefix); Assert.Equal(["@src/", "@src.txt"], values); } [Fact] public async Task FilePathProviderQuotesPathsWithSpaces() { Directory.CreateDirectory(Path.Combine(_basePath, "my folder")); WriteFile("my folder/test.txt"); var provider = new FilePathAutocompleteProvider(_basePath); var regular = await GetSuggestions(provider, "my", force: true); var attachment = await GetSuggestions(provider, "@my"); Assert.Contains("\"my folder/\"", regular?.Items.Select(static item => item.Value) ?? []); Assert.Contains("@\"my folder/\"", attachment?.Items.Select(static item => item.Value) ?? []); } [Fact] public async Task FilePathProviderContinuesInsideQuotedAttachmentPath() { Directory.CreateDirectory(Path.Combine(_basePath, "my folder")); WriteFile("my folder/other.txt"); WriteFile("my folder/test.txt"); var provider = new FilePathAutocompleteProvider(_basePath); const string line = "@\"my folder/\""; var result = await GetSuggestions(provider, line, cursorColumn: line.Length - 1); Assert.NotNull(result); var values = result.Items.Select(static item => item.Value).ToArray(); Assert.Equal("@\"my folder/", result.Prefix); Assert.Equal(["@\"my folder/other.txt\"", "@\"my folder/test.txt\""], values); } [Fact] public async Task FilePathProviderAppliesDirectoryAndFileCompletions() { Directory.CreateDirectory(Path.Combine(_basePath, "src")); WriteFile("src.txt"); var provider = new FilePathAutocompleteProvider(_basePath); var result = await GetSuggestions(provider, "@src"); var directory = provider.ApplyCompletion(new AutocompleteApplyContext(["see @src"], 0, "see @src".Length, result!.Items[0], result.Prefix)); var file = provider.ApplyCompletion(new AutocompleteApplyContext(["see @src"], 0, "see @src".Length, result.Items[1], result.Prefix)); Assert.Equal("see @src/", directory.Lines[0]); Assert.Equal("see @src.txt ", file.Lines[0]); Assert.Equal("see @src/".Length, directory.CursorColumn); Assert.Equal("see @src.txt ".Length, file.CursorColumn); } [Fact] public async Task FilePathProviderAppliesQuotedCompletionWithoutDuplicatingClosingQuote() { Directory.CreateDirectory(Path.Combine(_basePath, "my folder")); WriteFile("my folder/test.txt"); var provider = new FilePathAutocompleteProvider(_basePath); const string line = "@\"my folder/te\""; var result = await GetSuggestions(provider, line, cursorColumn: line.Length - 1); var item = Assert.Single(result!.Items); var applied = provider.ApplyCompletion(new AutocompleteApplyContext([line], 0, line.Length - 1, item, result.Prefix)); Assert.Equal("@\"my folder/test.txt\" ", applied.Lines[0]); Assert.Equal("@\"my folder/test.txt\" ".Length, applied.CursorColumn); } [Fact] public async Task FilePathProviderAppliesRegularQuotedCompletionWithoutDuplicatingClosingQuote() { Directory.CreateDirectory(Path.Combine(_basePath, "my folder")); WriteFile("my folder/test.txt"); var provider = new FilePathAutocompleteProvider(_basePath); const string line = "\"my folder/te\""; var result = await GetSuggestions(provider, line, force: true, cursorColumn: line.Length - 1); var item = Assert.Single(result!.Items); var applied = provider.ApplyCompletion(new AutocompleteApplyContext([line], 0, line.Length - 1, item, result.Prefix)); Assert.Equal("\"my folder/test.txt\" ", applied.Lines[0]); Assert.Equal("\"my folder/test.txt\" ".Length, applied.CursorColumn); } [Fact] public async Task CompositeProviderUsesFirstProviderWithSuggestions() { WriteFile("src.txt"); var provider = new CompositeAutocompleteProvider( [ new SlashCommandAutocompleteProvider([new SlashCommand("help")]), new FilePathAutocompleteProvider(_basePath), ]); var command = await GetSuggestions(provider, "/h"); var path = await GetSuggestions(provider, "src", force: true); Assert.Equal("/h", command?.Prefix); Assert.Equal("help", command?.Items.Single().Value); Assert.Equal("src", path?.Prefix); Assert.Equal("src.txt", path?.Items.Single().Value); } [Fact] public async Task SlashCommandProviderReturnsArgumentCompletions() { var provider = new SlashCommandAutocompleteProvider( [ new SlashCommand( "model", ArgumentCompletionProvider: (prefix, _) => ValueTask.FromResult?>( [new AutocompleteItem("gpt-5", "gpt-5", $"prefix:{prefix}")])) ]); var result = await GetSuggestions(provider, "/model gp"); Assert.NotNull(result); var item = Assert.Single(result.Items); Assert.Equal("gp", result.Prefix); Assert.Equal("gpt-5", item.Value); Assert.Equal("prefix:gp", item.Description); } [Fact] public async Task SlashCommandProviderAppliesArgumentCompletionWithoutRewritingCommandName() { var provider = new SlashCommandAutocompleteProvider( [ new SlashCommand( "model", ArgumentCompletionProvider: static (_, _) => ValueTask.FromResult?>( [AutocompleteItem.FromText("gpt-5")])) ]); const string line = "/model gp"; var result = await GetSuggestions(provider, line); var item = Assert.Single(result!.Items); var applied = provider.ApplyCompletion(new AutocompleteApplyContext([line], 0, line.Length, item, result.Prefix)); Assert.Equal("/model gpt-5", applied.Lines[0]); Assert.Equal("/model gpt-5".Length, applied.CursorColumn); } [Fact] public async Task SlashCommandProviderDoesNotRewriteCommandNameWhenArgumentPrefixIsEmpty() { var provider = new SlashCommandAutocompleteProvider( [ new SlashCommand( "model", ArgumentCompletionProvider: static (_, _) => ValueTask.FromResult?>( [AutocompleteItem.FromText("gpt-5")])) ]); const string line = "/model "; var result = await GetSuggestions(provider, line); var item = Assert.Single(result!.Items); var applied = provider.ApplyCompletion(new AutocompleteApplyContext([line], 0, line.Length, item, result.Prefix)); Assert.Equal("/model gpt-5", applied.Lines[0]); Assert.Equal("/model gpt-5".Length, applied.CursorColumn); } [Fact] public async Task SlashCommandProviderReturnsNoArgumentSuggestionsWithoutCallback() { var provider = new SlashCommandAutocompleteProvider([new SlashCommand("model")]); var result = await GetSuggestions(provider, "/model gp"); Assert.Null(result); } [Fact] public async Task SlashCommandProviderKeepsCommandNameCompletion() { var provider = new SlashCommandAutocompleteProvider( [ new SlashCommand("help", "show help"), new SlashCommand("history", "show history"), ]); var result = await GetSuggestions(provider, "/h"); Assert.NotNull(result); Assert.Equal("/h", result.Prefix); Assert.Equal(["help", "history"], result.Items.Select(static item => item.Value).ToArray()); } public void Dispose() { if (Directory.Exists(_basePath)) Directory.Delete(_basePath, true); } private async Task GetSuggestions( IAutocompleteProvider provider, string line, bool force = false, int? cursorColumn = null) => await provider.GetSuggestionsAsync(new AutocompleteRequest([line], 0, cursorColumn ?? line.Length, force)); private void WriteFile(string relativePath) { var path = Path.Combine(_basePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, string.Empty); } }