Files
ttui/test/TinyTUI.Tests/Autocomplete/AutocompleteProviderTests.cs
chuan 602e271f6c feat: add slash command argument autocomplete
- support argument completion callbacks on slash commands

- apply selected argument suggestions without rewriting command names

- add autocomplete provider regression tests
2026-06-04 09:59:11 +08:00

281 lines
11 KiB
C#

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<IReadOnlyList<AutocompleteItem>?>(
[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<IReadOnlyList<AutocompleteItem>?>(
[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<IReadOnlyList<AutocompleteItem>?>(
[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<AutocompleteSuggestions?> 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);
}
}