diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e7633c4..93b0b16 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,6 +7,12 @@ "commands": [ "nbgv" ] + }, + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ] } } } \ No newline at end of file diff --git a/.github/workflows/validate-and-package.yml b/.github/workflows/validate-and-package.yml index 0ef5c5b..01a037d 100644 --- a/.github/workflows/validate-and-package.yml +++ b/.github/workflows/validate-and-package.yml @@ -168,9 +168,44 @@ jobs: --no-restore --logger "trx;LogFileName=test-results.trx" --results-directory TestResults - --collect "Code coverage" + --collect "XPlat Code Coverage" shell: pwsh + - name: Generate code coverage report + if: always() + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $coverageFiles = Get-ChildItem -Path TestResults -Recurse -Filter 'coverage.cobertura.xml' -ErrorAction SilentlyContinue + if (-not $coverageFiles) { + Write-Host '::warning::No coverage files found; skipping coverage report.' + return + } + + $reports = ($coverageFiles.FullName -join ';') + dotnet tool run reportgenerator ` + "-reports:$reports" ` + "-targetdir:TestResults/coverage" ` + "-reporttypes:Html;MarkdownSummaryGithub;TextSummary" ` + "-title:CosmosDBShell Code Coverage" + if ($LASTEXITCODE -ne 0) { + throw "reportgenerator failed with exit code $LASTEXITCODE." + } + + $markdown = 'TestResults/coverage/SummaryGithub.md' + if (Test-Path $markdown) { + Get-Content $markdown | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + } + + - name: Upload code coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: code-coverage-report + path: TestResults/coverage + if-no-files-found: ignore + - name: Run fuzzer smoke test working-directory: CosmosDBShell.Fuzzer run: dotnet run --configuration $env:BUILD_CONFIGURATION --no-build --no-restore -- --all diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 623de88..3a387b7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -18,6 +18,24 @@ ] }, "problemMatcher": "$msCompile" + }, + { + "label": "coverage", + "detail": "Run unit tests with code coverage and generate a per-namespace report.", + "command": "pwsh", + "type": "process", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/tools/coverage.ps1" + ], + "windows": { + "command": "powershell" + }, + "group": "test", + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3091c8f..5000a0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ There are several ways you can contribute to the CosmosDBShell project: - Restore dependencies: `dotnet restore CosmosDBShell.sln` - Build: `dotnet build CosmosDBShell.sln` (or use the VS Code build task with Ctrl+Shift+B). - Run tests: `dotnet test CosmosDBShell.sln` + - Run tests with code coverage: `./tools/coverage.ps1` (or the VS Code `coverage` task). Generates an HTML report under `TestResults/coverage` and prints a per-namespace summary; use the report's "Group by" selector for namespace-level coverage. - Run the tool locally: `dotnet run --project CosmosDBShell/CosmosDBShell.csproj` - GitHub Actions runs CI and uploads NuGet package artifacts from [.github/workflows/validate-and-package.yml](.github/workflows/validate-and-package.yml). - Local builds and GitHub Actions use the default NuGet sources (nuget.org). The Azure DevOps pipeline uses [.pipelines/nuget.config](.pipelines/nuget.config) to restrict restores to the internal feed. diff --git a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs new file mode 100644 index 0000000..cfcc890 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Unit tests for . Covers the pure validation helper and +/// the offline state visitors that do not require a live Cosmos DB connection. +/// +public class BucketCommandTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(5)] + public void CheckBucket_WithinRange_ReturnsTrue(int bucket) + { + Assert.True(BucketCommand.CheckBucket(bucket)); + } + + [Theory] + [InlineData(-1)] + [InlineData(6)] + [InlineData(99)] + public void CheckBucket_OutOfRange_ReturnsFalse(int bucket) + { + Assert.False(BucketCommand.CheckBucket(bucket)); + } + + [Fact] + public async Task ExecuteAsync_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new BucketCommand { Bucket = 3 }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "bucket 3", CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_Connected_ThrowsNotInDatabase() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ConnectedState(CreateTestClient()); + var command = new BucketCommand { Bucket = 3 }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "bucket 3", CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_InDatabase_SetsBucket() + { + var client = CreateTestClient(); + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDatabase", client); + var command = new BucketCommand { Bucket = 3 }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 3", CancellationToken.None); + + Assert.False(state.IsError); + Assert.Equal(3, client.ClientOptions.ThroughputBucket); + } + + [Fact] + public async Task ExecuteAsync_InDatabase_ZeroResetsBucket() + { + var client = CreateTestClient(); + client.ClientOptions.ThroughputBucket = 4; + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDatabase", client); + var command = new BucketCommand { Bucket = 0 }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 0", CancellationToken.None); + + Assert.False(state.IsError); + Assert.Null(client.ClientOptions.ThroughputBucket); + } + + [Fact] + public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent() + { + var client = CreateTestClient(); + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ContainerState("TestContainer", "TestDatabase", client); + var command = new BucketCommand(); + + var state = await command.ExecuteAsync(shell, new CommandState(), "bucket", CancellationToken.None); + + Assert.False(state.IsError); + } + + [Fact] + public async Task ExecuteAsync_InDatabase_InvalidValue_ReturnsEmptyState() + { + var client = CreateTestClient(); + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDatabase", client); + var command = new BucketCommand { Bucket = 99 }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None); + + Assert.False(state.IsError); + Assert.Null(client.ClientOptions.ThroughputBucket); + } + + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/CatCommandTests.cs b/CosmosDBShell.Tests/CommandTests/CatCommandTests.cs new file mode 100644 index 0000000..9b98d35 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/CatCommandTests.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; + +/// +/// Unit tests for . The command reads a file from disk and +/// returns its contents as a result, throwing when the file +/// path is missing or does not exist. These paths run without a Cosmos DB connection. +/// +public class CatCommandTests : IDisposable +{ + private readonly string tempFile; + + public CatCommandTests() + { + this.tempFile = Path.Combine(Path.GetTempPath(), $"catcmd_{Guid.NewGuid():N}.txt"); + } + + public void Dispose() + { + if (File.Exists(this.tempFile)) + { + File.Delete(this.tempFile); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task ExecuteAsync_ExistingFile_ReturnsContentsAsShellText() + { + const string contents = "{ \"hello\": \"world\" }\nsecond line"; + await File.WriteAllTextAsync(this.tempFile, contents, TestContext.Current.CancellationToken); + + using var shell = ShellInterpreter.CreateInstance(); + var command = new CatCommand { FilePath = this.tempFile }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "cat", TestContext.Current.CancellationToken); + + var text = Assert.IsType(state.Result); + Assert.Equal(contents, text.Text); + } + + [Fact] + public async Task ExecuteAsync_EmptyFile_ReturnsEmptyShellText() + { + await File.WriteAllTextAsync(this.tempFile, string.Empty, TestContext.Current.CancellationToken); + + using var shell = ShellInterpreter.CreateInstance(); + var command = new CatCommand { FilePath = this.tempFile }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "cat", TestContext.Current.CancellationToken); + + var text = Assert.IsType(state.Result); + Assert.Equal(string.Empty, text.Text); + } + + [Fact] + public async Task ExecuteAsync_NonExistentFile_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + var command = new CatCommand { FilePath = this.tempFile }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "cat", CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_NullPath_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + var command = new CatCommand { FilePath = null }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "cat", CancellationToken.None)); + } + + [Fact] + public async Task StateVisitors_ReturnZero() + { + using var shell = ShellInterpreter.CreateInstance(); + var command = new CatCommand(); + + var ct = TestContext.Current.CancellationToken; + Assert.Equal(0, await command.VisitConnectedStateAsync(null!, string.Empty, ct)); + Assert.Equal(0, await command.VisitContainerStateAsync(null!, string.Empty, ct)); + Assert.Equal(0, await command.VisitDatabaseStateAsync(null!, string.Empty, ct)); + Assert.Equal(0, await command.VisitDisconnectedStateAsync(null!, string.Empty, ct)); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/ContainerScopedCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ContainerScopedCommandTests.cs new file mode 100644 index 0000000..2e6f051 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/ContainerScopedCommandTests.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Offline unit tests for , , +/// and . These cover the not-connected and wrong-scope branches +/// that execute before any network or external-process call. +/// +public class ContainerScopedCommandTests +{ + [Fact] + public async Task IndexPolicy_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new IndexPolicyCommand(); + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "indexpolicy", CancellationToken.None)); + } + + [Fact] + public async Task IndexPolicy_Connected_NoTarget_ThrowsNotInContainer() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ConnectedState(CreateTestClient()); + var command = new IndexPolicyCommand(); + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "indexpolicy", CancellationToken.None)); + } + + [Fact] + public async Task IndexPolicy_InDatabase_NoContainer_ThrowsNotInContainer() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDatabase", CreateTestClient()); + var command = new IndexPolicyCommand(); + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "indexpolicy", CancellationToken.None)); + } + + [Fact] + public async Task RmContainer_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new RmContainerCommand { Name = "MyContainer", Force = true }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rmcon MyContainer true", CancellationToken.None)); + } + + [Fact] + public async Task RmContainer_Connected_NoDatabase_ThrowsNotInDatabase() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ConnectedState(CreateTestClient()); + var command = new RmContainerCommand { Name = "MyContainer", Force = true }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rmcon MyContainer true", CancellationToken.None)); + } + + [Fact] + public async Task RmContainer_InContainer_NoDatabaseOption_ThrowsNotInContainer() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ContainerState("TestContainer", "TestDatabase", CreateTestClient()); + var command = new RmContainerCommand { Name = "MyContainer", Force = true }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rmcon MyContainer true", CancellationToken.None)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Edit_MissingPath_ThrowsCommandException(string? path) + { + using var shell = ShellInterpreter.CreateInstance(); + var command = new EditCommand { FilePath = path }; + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "edit", CancellationToken.None)); + Assert.Equal(MessageService.GetString("command-edit-missing-path"), ex.Message); + } + + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/DeleteCommandTests.cs b/CosmosDBShell.Tests/CommandTests/DeleteCommandTests.cs new file mode 100644 index 0000000..9a92147 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/DeleteCommandTests.cs @@ -0,0 +1,79 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Unit tests for . Covers the offline routing branches: +/// the invalid item-type error and the not-connected paths of the routed commands. +/// +public class DeleteCommandTests +{ + [Theory] + [InlineData("bogus")] + [InlineData("")] + [InlineData("itemz")] + public async Task ExecuteAsync_InvalidItemType_ThrowsCommandException(string item) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new DeleteCommand { Item = item, Pattern = "x" }; + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), $"delete {item} x", CancellationToken.None)); + Assert.Equal(MessageService.GetString("command-delete-error-invalid_item_type"), ex.Message); + } + + [Fact] + public async Task ExecuteAsync_Database_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new DeleteCommand { Item = "database", Pattern = "MyDb" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "delete database MyDb", CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_Container_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new DeleteCommand { Item = "container", Pattern = "MyContainer" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "delete container MyContainer", CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_Item_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new DeleteCommand { Item = "item", Pattern = "test-*" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "delete item test-*", CancellationToken.None)); + } + + [Fact] + public void CreateCommand_ItemTypeClassifiers_MatchExpected() + { + Assert.True(CreateCommand.IsItem("ITEM")); + Assert.True(CreateCommand.IsItem("I")); + Assert.True(CreateCommand.IsContainer("CONTAINER")); + Assert.True(CreateCommand.IsContainer("C")); + Assert.True(CreateCommand.IsDatabase("DATABASE")); + Assert.True(CreateCommand.IsDatabase("DB")); + Assert.False(CreateCommand.IsItem("CONTAINER")); + Assert.False(CreateCommand.IsDatabase("ITEM")); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/ItemWriteCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ItemWriteCommandTests.cs new file mode 100644 index 0000000..e152aba --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/ItemWriteCommandTests.cs @@ -0,0 +1,124 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Offline unit tests for the item write commands (, +/// , ). These cover the +/// argument validation and not-connected branches that execute before any network call. +/// +public class ItemWriteCommandTests +{ + [Fact] + public async Task Print_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new PrintCommand { Id = "item-1", PartitionKey = "pk-1" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "print item-1 pk-1", CancellationToken.None)); + } + + [Fact] + public async Task Replace_NoInputData_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new ReplaceCommand(); + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "replace", CancellationToken.None)); + Assert.Equal(MessageService.GetString("error-no_input_data"), ex.Message); + } + + [Fact] + public async Task Replace_WithData_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new ReplaceCommand { Data = "{\"id\":\"1\"}" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "replace '{\"id\":\"1\"}'", CancellationToken.None)); + } + + [Fact] + public async Task Patch_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new PatchCommand { Op = "set", Id = "1", Key = "1", Path = "/a", Value = "b" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "patch set 1 1 /a b", CancellationToken.None)); + } + + [Fact] + public async Task Patch_MissingId_ThrowsCommandException() + { + var command = new PatchCommand { Op = "set", Id = null, Key = "1", Path = "/a", Value = "b" }; + + var ex = await ExecutePatchInDatabaseAsync(command); + Assert.Equal(MessageService.GetString("command-patch-error-missing_id"), ex.Message); + } + + [Fact] + public async Task Patch_MissingKey_ThrowsCommandException() + { + var command = new PatchCommand { Op = "set", Id = "1", Key = null, Path = "/a", Value = "b" }; + + var ex = await ExecutePatchInDatabaseAsync(command); + Assert.Equal(MessageService.GetString("command-patch-error-missing_pk"), ex.Message); + } + + [Fact] + public async Task Patch_MissingOp_ThrowsCommandException() + { + var command = new PatchCommand { Op = null, Id = "1", Key = "1", Path = "/a", Value = "b" }; + + var ex = await ExecutePatchInDatabaseAsync(command); + Assert.Equal(MessageService.GetString("command-patch-error-missing_op"), ex.Message); + } + + [Fact] + public async Task Patch_UnsupportedOp_ThrowsCommandException() + { + var command = new PatchCommand { Op = "frobnicate", Id = "1", Key = "1", Path = "/a", Value = "b" }; + + var ex = await ExecutePatchInDatabaseAsync(command); + Assert.Contains("frobnicate", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Patch_InvalidPath_ThrowsCommandException() + { + var command = new PatchCommand { Op = "set", Id = "1", Key = "1", Path = "no-leading-slash", Value = "b" }; + + var ex = await ExecutePatchInDatabaseAsync(command); + Assert.Equal(MessageService.GetString("command-patch-error-invalid_path"), ex.Message); + } + + private static async Task ExecutePatchInDatabaseAsync(PatchCommand command) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDatabase", CreateTestClient()); + + return await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "patch", CancellationToken.None)); + } + + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/MakeDbCommandTests.cs b/CosmosDBShell.Tests/CommandTests/MakeDbCommandTests.cs new file mode 100644 index 0000000..4fb3209 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/MakeDbCommandTests.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Unit tests for . Covers the pure throughput helper and +/// the disallowed-state visitors, all of which run without a live Cosmos DB connection. +/// +public class MakeDbCommandTests +{ + [Theory] + [InlineData("manual")] + [InlineData("MANUAL")] + [InlineData("m")] + [InlineData("M")] + public void CreateThroughputProperties_Manual_UsesManualThroughput(string scale) + { + var properties = MakeDbCommand.CreateThroughputProperties(scale, 1500); + + Assert.Equal(1500, properties.Throughput); + } + + [Theory] + [InlineData("auto")] + [InlineData(null)] + [InlineData("something-else")] + public void CreateThroughputProperties_NonManual_UsesAutoscaleThroughput(string? scale) + { + var properties = MakeDbCommand.CreateThroughputProperties(scale, 4000); + + Assert.Equal(4000, properties.AutoscaleMaxThroughput); + } + + [Fact] + public void CreateThroughputProperties_NoRu_DefaultsTo1000() + { + var properties = MakeDbCommand.CreateThroughputProperties("manual", null); + + Assert.Equal(1000, properties.Throughput); + } + + [Fact] + public async Task ExecuteAsync_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new MakeDbCommand { Name = "MyDb" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "mkdb MyDb", CancellationToken.None)); + } + + [Fact] + public async Task ExecuteAsync_InDatabase_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDatabase", CreateTestClient()); + var command = new MakeDbCommand { Name = "MyDb" }; + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "mkdb MyDb", CancellationToken.None)); + Assert.Equal(MessageService.GetString("error-not_allowed_in_db"), ex.Message); + } + + [Fact] + public async Task ExecuteAsync_InContainer_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ContainerState("TestContainer", "TestDatabase", CreateTestClient()); + var command = new MakeDbCommand { Name = "MyDb" }; + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "mkdb MyDb", CancellationToken.None)); + Assert.Equal(MessageService.GetString("error-not_allowed_in_container"), ex.Message); + } + + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs b/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs index e7a7511..58c4ce9 100644 --- a/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs +++ b/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs @@ -3,6 +3,10 @@ // ------------------------------------------------------------ using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; namespace CosmosShell.Tests.CommandTests; @@ -32,4 +36,78 @@ public void ParseJsonList() Assert.Equal("Bar", dict["foo"]); Assert.Equal(42, (int)dict["test"]); } + + [Fact] + public void ParseJson_NumericKinds_PromoteToNarrowestType() + { + var dict = Assert.IsAssignableFrom>( + MakeItemCommand.ParseJson( + "{ \"i\": 7, \"l\": 5000000000, \"d\": 1.5 }")); + + Assert.Equal(7, Assert.IsType(dict["i"])); + Assert.Equal(5000000000L, Assert.IsType(dict["l"])); + Assert.Equal(1.5d, Assert.IsType(dict["d"])); + } + + [Fact] + public void ParseJson_BooleansAndNull_MapToClrEquivalents() + { + var dict = Assert.IsAssignableFrom>( + MakeItemCommand.ParseJson( + "{ \"t\": true, \"f\": false, \"n\": null }")); + + Assert.True(Assert.IsType(dict["t"])); + Assert.False(Assert.IsType(dict["f"])); + Assert.Null(dict["n"]); + } + + [Fact] + public void ParseJson_NestedArraysAndObjects_AreParsedRecursively() + { + var dict = Assert.IsAssignableFrom>( + MakeItemCommand.ParseJson( + "{ \"tags\": [\"a\", \"b\"], \"meta\": { \"k\": 1 } }")); + + var tags = Assert.IsAssignableFrom>(dict["tags"]); + Assert.Equal(["a", "b"], tags.Cast().ToArray()); + + var meta = Assert.IsAssignableFrom>(dict["meta"]); + Assert.Equal(1, Assert.IsType(meta["k"])); + } + + [Fact] + public async Task ExecuteAsync_NoDataAndNoPipeInput_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new MakeItemCommand { Data = null }; + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "mkitem", TestContext.Current.CancellationToken)); + Assert.Equal(MessageService.GetString("error-no_input_data"), ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WithData_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new MakeItemCommand { Data = "{\"id\":\"1\"}" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "mkitem '{\"id\":\"1\"}'", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ExecuteAsync_PipeInput_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new MakeItemCommand(); + var commandState = new CommandState { Result = new ShellText("{\"id\":\"2\"}") }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, commandState, "mkitem", TestContext.Current.CancellationToken)); + } } + diff --git a/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs b/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs index fd96424..974a2b3 100644 --- a/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs @@ -6,9 +6,63 @@ namespace CosmosShell.Tests.CommandTests; using System.Text.Json; using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; public class RmCommandTests { + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } + + [Fact] + public async Task ExecuteAsync_NoPatternAndNoPipeInput_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + var command = new RmCommand { Pattern = null }; + + var ex = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rm", TestContext.Current.CancellationToken)); + Assert.Equal(MessageService.GetString("command-rm-error-no_filter"), ex.Message); + } + + [Fact] + public async Task ExecuteAsync_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new RmCommand { Pattern = "test-*" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rm test-*", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ExecuteAsync_ConnectedWithoutDatabaseAndContainer_ThrowsNotInContainer() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ConnectedState(CreateTestClient()); + var command = new RmCommand { Pattern = "test-*" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rm test-*", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ExecuteAsync_InDatabaseWithoutContainer_ThrowsNotInContainer() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DatabaseState("TestDb", CreateTestClient()); + var command = new RmCommand { Pattern = "test-*" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "rm test-*", TestContext.Current.CancellationToken)); + } + [Fact] public void TryGetPartitionKeyElements_ReturnsAllHierarchicalValues() { diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs new file mode 100644 index 0000000..1aeea43 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs @@ -0,0 +1,294 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.IO; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Offline tests for 's action dispatch. These cover the +/// read-only actions (current, list, show), the in-memory theme switching actions +/// (use/set), and the file-backed load/save/validate/reload branches, all without +/// launching an external editor or file browser. Tests that mutate the global +/// save and restore it, and use unique file names so +/// they do not collide with the real user themes directory. +/// +[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] +public class ThemeCommandDispatchTests +{ + private const string ValidToml = + """ + name = "placeholder" + + [colors] + literal = "purple" + """; + + [Fact] + public async Task NoAction_DefaultsToCurrent() + { + var state = await RunAsync(new ThemeCommand()); + + Assert.True(state.IsPrinted); + var json = Assert.IsType(state.Result); + Assert.True(json.Value.TryGetProperty("active", out _)); + } + + [Fact] + public async Task Current_ReturnsActiveThemeName() + { + var state = await RunAsync(new ThemeCommand { Action = "current" }); + + var json = Assert.IsType(state.Result); + Assert.False(string.IsNullOrEmpty(json.Value.GetProperty("active").GetString())); + } + + [Fact] + public async Task List_IncludesBuiltInThemes() + { + var state = await RunAsync(new ThemeCommand { Action = "list" }); + + var json = Assert.IsType(state.Result); + var names = json.Value.GetProperty("themes").EnumerateArray() + .Select(t => t.GetProperty("name").GetString()) + .ToList(); + Assert.Contains("default", names); + Assert.Contains("light", names); + } + + [Theory] + [InlineData("default")] + [InlineData("light")] + [InlineData("monochrome")] + public async Task Show_KnownTheme_PreviewsWithoutChangingActive(string name) + { + var saved = Theme.Current; + try + { + var state = await RunAsync(new ThemeCommand { Action = "show", Name = name }); + + var json = Assert.IsType(state.Result); + Assert.Equal(name, json.Value.GetProperty("previewed").GetString()); + Assert.Same(saved, Theme.Current); + } + finally + { + Theme.Apply(saved); + } + } + + [Fact] + public async Task Show_UnknownTheme_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" }); + + Assert.IsType(state); + } + + [Fact] + public async Task Use_KnownTheme_AppliesIt() + { + var saved = Theme.Current; + try + { + var state = await RunAsync(new ThemeCommand { Action = "use", Name = "light" }); + + var json = Assert.IsType(state.Result); + Assert.Equal("light", json.Value.GetProperty("applied").GetString()); + } + finally + { + Theme.Apply(saved); + } + } + + [Fact] + public async Task Set_AliasOfUse_AppliesTheme() + { + var saved = Theme.Current; + try + { + var state = await RunAsync(new ThemeCommand { Action = "set", Name = "dark" }); + + var json = Assert.IsType(state.Result); + Assert.Equal("dark", json.Value.GetProperty("applied").GetString()); + } + finally + { + Theme.Apply(saved); + } + } + + [Fact] + public async Task Use_MissingName_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "use" }); + + Assert.IsType(state); + } + + [Fact] + public async Task Use_UnknownName_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" }); + + Assert.IsType(state); + } + + [Fact] + public async Task Load_MissingPath_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "load" }); + + Assert.IsType(state); + } + + [Fact] + public async Task Load_ValidFile_LoadsAndApplies() + { + var name = $"load-{Guid.NewGuid():N}"; + var path = WriteTempToml(name, ValidToml); + var saved = Theme.Current; + try + { + var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); + + var json = Assert.IsType(state.Result); + Assert.Equal("placeholder", json.Value.GetProperty("loaded").GetString()); + } + finally + { + Theme.Apply(saved); + File.Delete(path); + } + } + + [Fact] + public async Task Load_NonExistentFile_ReturnsError() + { + var path = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.toml"); + + var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); + + Assert.IsType(state); + } + + [Fact] + public async Task Load_InvalidToml_ReturnsError() + { + var path = WriteTempToml($"bad-{Guid.NewGuid():N}", "this is = = not valid toml [[["); + try + { + var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); + + Assert.IsType(state); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task Save_MissingName_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "save" }); + + Assert.IsType(state); + } + + [Fact] + public async Task Save_InvalidName_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "save", Name = "../escape" }); + + Assert.IsType(state); + } + + [Fact] + public async Task Save_ToExplicitPath_WritesFile() + { + var path = Path.Combine(Path.GetTempPath(), $"save-{Guid.NewGuid():N}.toml"); + try + { + var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path }); + + var json = Assert.IsType(state.Result); + Assert.Equal("exported", json.Value.GetProperty("saved").GetString()); + Assert.True(File.Exists(path)); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public async Task Save_ExistingFileWithoutForce_ReturnsError() + { + var path = Path.Combine(Path.GetTempPath(), $"save-{Guid.NewGuid():N}.toml"); + File.WriteAllText(path, "name = \"existing\""); + try + { + var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path }); + + Assert.IsType(state); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task Save_ExistingFileWithForce_Overwrites() + { + var path = Path.Combine(Path.GetTempPath(), $"save-{Guid.NewGuid():N}.toml"); + File.WriteAllText(path, "name = \"existing\""); + try + { + var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path, Force = true }); + + var json = Assert.IsType(state.Result); + Assert.Equal("exported", json.Value.GetProperty("saved").GetString()); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task Reload_RescansUserThemesDirectory() + { + var state = await RunAsync(new ThemeCommand { Action = "reload" }); + + var json = Assert.IsType(state.Result); + Assert.True(json.Value.TryGetProperty("reloaded", out _)); + } + + [Fact] + public async Task UnknownAction_ReturnsError() + { + var state = await RunAsync(new ThemeCommand { Action = "frobnicate" }); + + Assert.IsType(state); + } + + private static Task RunAsync(ThemeCommand command) => + command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), string.Empty, CancellationToken.None); + + private static string WriteTempToml(string name, string content) + { + var path = Path.Combine(Path.GetTempPath(), name + ".toml"); + File.WriteAllText(path, content); + return path; + } +} diff --git a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs index 4341866..d890fa2 100644 --- a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs @@ -5,11 +5,14 @@ namespace CosmosShell.Tests.CommandTests; using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; /// /// Unit tests for . Covers the pure helpers that /// parse change feed response bodies and build the change feed start position, -/// which can be exercised without a live Cosmos DB connection. +/// plus the option-validation and not-connected guards, all of which can be +/// exercised without a live Cosmos DB connection. /// public class WatchCommandTests { @@ -86,4 +89,33 @@ public void ResolveInterval_ValueBelowMinimum_IsClamped(double seconds) { Assert.Equal(TimeSpan.FromSeconds(0.1), WatchCommand.ResolveInterval(seconds)); } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void ResolveInterval_NonFiniteValue_ThrowsCommandException(double seconds) + { + Assert.Throws(() => WatchCommand.ResolveInterval(seconds)); + } + + [Fact] + public async Task ExecuteAsync_NullShell_ThrowsArgumentNullException() + { + var command = new WatchCommand(); + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(null!, new CommandState(), "watch", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ExecuteAsync_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new WatchCommand(); + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + } } diff --git a/CosmosDBShell.Tests/Lsp/CosmosShellWorkspaceTests.cs b/CosmosDBShell.Tests/Lsp/CosmosShellWorkspaceTests.cs new file mode 100644 index 0000000..819ea96 --- /dev/null +++ b/CosmosDBShell.Tests/Lsp/CosmosShellWorkspaceTests.cs @@ -0,0 +1,164 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Lsp; + +using System.Linq; + +using Azure.Data.Cosmos.Shell.Lsp; + +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +using Xunit; + +public class CosmosShellWorkspaceTests +{ + private readonly CosmosShellWorkspace workspace = new(); + private readonly DocumentUri uri = DocumentUri.From("file:///ws.csh"); + + [Fact] + public void OpenDocument_StoresAndParses() + { + this.workspace.OpenDocument(this.uri, "echo hello", 3); + + var doc = this.workspace.GetDocument(this.uri); + Assert.NotNull(doc); + Assert.Equal("echo hello", doc!.Content); + Assert.Equal(3, doc.Version); + Assert.NotNull(doc.LastParseResult); + Assert.True(doc.LastParseResult!.Success); + } + + [Fact] + public void GetDocument_Unknown_ReturnsNull() + { + Assert.Null(this.workspace.GetDocument(DocumentUri.From("file:///missing.csh"))); + } + + [Fact] + public void Documents_ReflectsOpenSet() + { + var other = DocumentUri.From("file:///other.csh"); + this.workspace.OpenDocument(this.uri, "echo a", 1); + this.workspace.OpenDocument(other, "echo b", 1); + + Assert.Equal(2, this.workspace.Documents.Count()); + } + + [Fact] + public void UpdateDocument_Existing_UpdatesContentAndVersion() + { + this.workspace.OpenDocument(this.uri, "echo a", 1); + this.workspace.UpdateDocument(this.uri, "echo b", 2); + + var doc = this.workspace.GetDocument(this.uri); + Assert.Equal("echo b", doc!.Content); + Assert.Equal(2, doc.Version); + } + + [Fact] + public void UpdateDocument_Missing_OpensDocument() + { + this.workspace.UpdateDocument(this.uri, "echo new", 5); + + var doc = this.workspace.GetDocument(this.uri); + Assert.NotNull(doc); + Assert.Equal("echo new", doc!.Content); + Assert.Equal(5, doc.Version); + } + + [Fact] + public void CloseDocument_RemovesIt() + { + this.workspace.OpenDocument(this.uri, "echo a", 1); + this.workspace.CloseDocument(this.uri); + + Assert.Null(this.workspace.GetDocument(this.uri)); + Assert.Empty(this.workspace.Documents); + } + + [Fact] + public void GetParseResult_ReturnsDocumentResult() + { + this.workspace.OpenDocument(this.uri, "echo a", 1); + + var result = this.workspace.GetParseResult(this.uri); + Assert.NotNull(result); + Assert.True(result!.Success); + } + + [Fact] + public void GetParseResult_Unknown_ReturnsNull() + { + Assert.Null(this.workspace.GetParseResult(this.uri)); + } + + [Fact] + public void GetWordAtPosition_Unknown_ReturnsNull() + { + Assert.Null(this.workspace.GetWordAtPosition(this.uri, new Position(0, 0))); + } + + [Fact] + public void GetWordAtPosition_ReturnsWord() + { + this.workspace.OpenDocument(this.uri, "echo $foo", 1); + + var word = this.workspace.GetWordAtPosition(this.uri, new Position(0, 6)); + Assert.Equal("$foo", word); + } + + [Fact] + public void GetWordAtPosition_LineOutOfRange_ReturnsNull() + { + this.workspace.OpenDocument(this.uri, "echo a", 1); + + Assert.Null(this.workspace.GetWordAtPosition(this.uri, new Position(10, 0))); + } + + [Fact] + public void GetWordAtPosition_CharOutOfRange_ReturnsNull() + { + this.workspace.OpenDocument(this.uri, "echo", 1); + + Assert.Null(this.workspace.GetWordAtPosition(this.uri, new Position(0, 20))); + } + + [Fact] + public void GetWordAtPosition_AtWordEnd_ReturnsPrecedingWord() + { + this.workspace.OpenDocument(this.uri, "a b", 1); + + Assert.Equal("a", this.workspace.GetWordAtPosition(this.uri, new Position(0, 1))); + } + + [Fact] + public void GetCompletionContext_Unknown_ReturnsEmpty() + { + var context = this.workspace.GetCompletionContext(this.uri, new Position(0, 0)); + Assert.Same(Azure.Data.Cosmos.Shell.Lsp.CompletionContext.Empty, context); + } + + [Fact] + public void GetCompletionContext_ReturnsContextWithText() + { + this.workspace.OpenDocument(this.uri, "echo hello", 1); + + var context = this.workspace.GetCompletionContext(this.uri, new Position(0, 4)); + Assert.Equal("echo", context.TextUpToPosition); + Assert.NotNull(context.Document); + } + + [Fact] + public void OpenDocument_WithParseError_ProducesDiagnostics() + { + // Unterminated string should yield lexer errors -> diagnostics. + this.workspace.OpenDocument(this.uri, "echo \"unterminated", 1); + + var doc = this.workspace.GetDocument(this.uri); + Assert.NotNull(doc); + Assert.NotEmpty(doc!.Diagnostics); + } +} diff --git a/CosmosDBShell.Tests/Lsp/FoldingRangeHandlerTests.cs b/CosmosDBShell.Tests/Lsp/FoldingRangeHandlerTests.cs new file mode 100644 index 0000000..9b5397e --- /dev/null +++ b/CosmosDBShell.Tests/Lsp/FoldingRangeHandlerTests.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Lsp; + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Lsp; + +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +using Xunit; + +public class FoldingRangeHandlerTests +{ + private readonly CosmosShellWorkspace workspace = new(); + private readonly FoldingRangeHandler handler; + private readonly DocumentUri uri = DocumentUri.From("file:///fold.csh"); + + public FoldingRangeHandlerTests() + { + this.handler = new FoldingRangeHandler(this.workspace); + } + + private Task?> HandleAsync() + { + return this.handler.Handle( + new FoldingRangeRequestParam { TextDocument = new TextDocumentIdentifier { Uri = this.uri } }, + CancellationToken.None); + } + + [Fact] + public async Task Handle_NoDocument_ReturnsNull() + { + var result = await this.HandleAsync(); + Assert.Null(result); + } + + [Fact] + public async Task Handle_NoBraces_ReturnsEmpty() + { + this.workspace.OpenDocument(this.uri, "echo hello\necho world", 1); + + var result = await this.HandleAsync(); + + Assert.NotNull(result); + Assert.Empty(result!); + } + + [Fact] + public async Task Handle_SingleLineBraces_NotFolded() + { + this.workspace.OpenDocument(this.uri, "if true { echo x }", 1); + + var result = await this.HandleAsync(); + + Assert.NotNull(result); + Assert.Empty(result!); + } + + [Fact] + public async Task Handle_MultiLineBraces_ProducesRange() + { + this.workspace.OpenDocument(this.uri, "if true {\n echo x\n}", 1); + + var result = await this.HandleAsync(); + + Assert.NotNull(result); + var range = Assert.Single(result!); + Assert.Equal(0, range.StartLine); + Assert.Equal(2, range.EndLine); + Assert.Equal(FoldingRangeKind.Region, range.Kind); + } + + [Fact] + public async Task Handle_NestedBraces_ProducesSortedRanges() + { + this.workspace.OpenDocument(this.uri, "if a {\n if b {\n echo x\n }\n}", 1); + + var result = await this.HandleAsync(); + + Assert.NotNull(result); + var ranges = result!.ToList(); + Assert.Equal(2, ranges.Count); + + // Sorted by start line ascending. + Assert.Equal(0, ranges[0].StartLine); + Assert.Equal(1, ranges[1].StartLine); + } + + [Fact] + public async Task Handle_UnbalancedCloseBrace_Ignored() + { + this.workspace.OpenDocument(this.uri, "echo x\n}\n}", 1); + + var result = await this.HandleAsync(); + + Assert.NotNull(result); + Assert.Empty(result!); + } + + [Fact] + public void GetRegistrationOptions_TargetsCosmosShell() + { + var options = this.handler.GetRegistrationOptions(); + Assert.NotNull(options.DocumentSelector); + } +} diff --git a/CosmosDBShell.Tests/Parser/AstVisitorWalkTests.cs b/CosmosDBShell.Tests/Parser/AstVisitorWalkTests.cs new file mode 100644 index 0000000..f8bcc29 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/AstVisitorWalkTests.cs @@ -0,0 +1,306 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Collections.Generic; + +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Walks parsed ASTs with a concrete subclass to exercise the +/// default traversal behavior of the base visitor (token visits and child recursion). +/// +public class AstVisitorWalkTests +{ + private sealed class RecordingVisitor : AstVisitor + { + public HashSet Visited { get; } = new(); + + public int TokenCount { get; private set; } + + public override void VisitToken(Token token) + { + this.TokenCount++; + } + + public override void Visit(ErrorExpression errorExpression) + { + this.Visited.Add(nameof(ErrorExpression)); + base.Visit(errorExpression); + } + + public override void Visit(ConstantExpression constantExpression) + { + this.Visited.Add(nameof(ConstantExpression)); + base.Visit(constantExpression); + } + + public override void Visit(UnaryOperatorExpression unaryOperatorExpression) + { + this.Visited.Add(nameof(UnaryOperatorExpression)); + base.Visit(unaryOperatorExpression); + } + + public override void Visit(BinaryOperatorExpression binaryOperatorExpression) + { + this.Visited.Add(nameof(BinaryOperatorExpression)); + base.Visit(binaryOperatorExpression); + } + + public override void Visit(FilterPipeExpression filterPipeExpression) + { + this.Visited.Add(nameof(FilterPipeExpression)); + base.Visit(filterPipeExpression); + } + + public override void Visit(ParensExpression parensExpression) + { + this.Visited.Add(nameof(ParensExpression)); + base.Visit(parensExpression); + } + + public override void Visit(JsonExpression jsonExpression) + { + this.Visited.Add(nameof(JsonExpression)); + base.Visit(jsonExpression); + } + + public override void Visit(JsonArrayExpression jSonArrayExpression) + { + this.Visited.Add(nameof(JsonArrayExpression)); + base.Visit(jSonArrayExpression); + } + + public override void Visit(JSonPathExpression jSonPathExpression) + { + this.Visited.Add(nameof(JSonPathExpression)); + base.Visit(jSonPathExpression); + } + + public override void Visit(FilterPathExpression filterPathExpression) + { + this.Visited.Add(nameof(FilterPathExpression)); + base.Visit(filterPathExpression); + } + + public override void Visit(FilterCallExpression filterCallExpression) + { + this.Visited.Add(nameof(FilterCallExpression)); + base.Visit(filterCallExpression); + } + + public override void Visit(InterpolatedStringExpression interpolatedStringExpression) + { + this.Visited.Add(nameof(InterpolatedStringExpression)); + base.Visit(interpolatedStringExpression); + } + + public override void Visit(VariableExpression variableExpression) + { + this.Visited.Add(nameof(VariableExpression)); + base.Visit(variableExpression); + } + + public override void Visit(CommandExpression commandExpression) + { + this.Visited.Add(nameof(CommandExpression)); + base.Visit(commandExpression); + } + + public override void Visit(CommandOption commandOption) + { + this.Visited.Add(nameof(CommandOption)); + base.Visit(commandOption); + } + + public override void Visit(AssignmentStatement assignmentStatement) + { + this.Visited.Add(nameof(AssignmentStatement)); + base.Visit(assignmentStatement); + } + + public override void Visit(BlockStatement blockStatement) + { + this.Visited.Add(nameof(BlockStatement)); + base.Visit(blockStatement); + } + + public override void Visit(BreakStatement breakStatement) + { + this.Visited.Add(nameof(BreakStatement)); + base.Visit(breakStatement); + } + + public override void Visit(CommandStatement commandStatement) + { + this.Visited.Add(nameof(CommandStatement)); + base.Visit(commandStatement); + } + + public override void Visit(ContinueStatement continueStatement) + { + this.Visited.Add(nameof(ContinueStatement)); + base.Visit(continueStatement); + } + + public override void Visit(DefStatement defStatement) + { + this.Visited.Add(nameof(DefStatement)); + base.Visit(defStatement); + } + + public override void Visit(DoWhileStatement doWhileStatement) + { + this.Visited.Add(nameof(DoWhileStatement)); + base.Visit(doWhileStatement); + } + + public override void Visit(ExecStatement execStatement) + { + this.Visited.Add(nameof(ExecStatement)); + base.Visit(execStatement); + } + + public override void Visit(ForStatement forStatement) + { + this.Visited.Add(nameof(ForStatement)); + base.Visit(forStatement); + } + + public override void Visit(IfStatement ifStatement) + { + this.Visited.Add(nameof(IfStatement)); + base.Visit(ifStatement); + } + + public override void Visit(LoopStatement loopStatement) + { + this.Visited.Add(nameof(LoopStatement)); + base.Visit(loopStatement); + } + + public override void Visit(PipeStatement pipeStatement) + { + this.Visited.Add(nameof(PipeStatement)); + base.Visit(pipeStatement); + } + + public override void Visit(ReturnStatement returnStatement) + { + this.Visited.Add(nameof(ReturnStatement)); + base.Visit(returnStatement); + } + + public override void Visit(WhileStatement whileStatement) + { + this.Visited.Add(nameof(WhileStatement)); + base.Visit(whileStatement); + } + } + + private static List Parse(string script) + { + return new StatementParser(script).ParseStatements(); + } + + private static Expression ParseFilterExpr(string input) + { + return new ExpressionParser(new Lexer(input)).ParseFilterExpression(); + } + + private static void Accept(RecordingVisitor visitor, string script) + { + foreach (var statement in Parse(script)) + { + statement.Accept(visitor); + } + } + + [Fact] + public void Walk_AllStatementTypes_AreVisited() + { + var visitor = new RecordingVisitor(); + + Accept(visitor, "$x = 1"); + Accept(visitor, "{ echo hi }"); + Accept(visitor, "break"); + Accept(visitor, "echo hello"); + Accept(visitor, "continue"); + Accept(visitor, "def f() { return 1 }"); + Accept(visitor, "do { break } while $x"); + Accept(visitor, "exec foo"); + Accept(visitor, "for $i in [1, 2] echo $i"); + Accept(visitor, "if $x { echo a } else { echo b }"); + Accept(visitor, "loop break"); + Accept(visitor, "echo a | echo b"); + Accept(visitor, "return 1"); + Accept(visitor, "while $x break"); + + Assert.Contains(nameof(AssignmentStatement), visitor.Visited); + Assert.Contains(nameof(BlockStatement), visitor.Visited); + Assert.Contains(nameof(BreakStatement), visitor.Visited); + Assert.Contains(nameof(CommandStatement), visitor.Visited); + Assert.Contains(nameof(ContinueStatement), visitor.Visited); + Assert.Contains(nameof(DefStatement), visitor.Visited); + Assert.Contains(nameof(DoWhileStatement), visitor.Visited); + Assert.Contains(nameof(ExecStatement), visitor.Visited); + Assert.Contains(nameof(ForStatement), visitor.Visited); + Assert.Contains(nameof(IfStatement), visitor.Visited); + Assert.Contains(nameof(LoopStatement), visitor.Visited); + Assert.Contains(nameof(PipeStatement), visitor.Visited); + Assert.Contains(nameof(ReturnStatement), visitor.Visited); + Assert.Contains(nameof(WhileStatement), visitor.Visited); + Assert.True(visitor.TokenCount > 0); + } + + [Fact] + public void Walk_AllExpressionTypes_AreVisited() + { + var visitor = new RecordingVisitor(); + + ParseFilterExpr("1 + 2").Accept(visitor); + ParseFilterExpr("!$x").Accept(visitor); + ParseFilterExpr("(1)").Accept(visitor); + ParseFilterExpr("[1, 2, 3]").Accept(visitor); + ParseFilterExpr("{ id, status }").Accept(visitor); + ParseFilterExpr("\"hello $x world\"").Accept(visitor); + ParseFilterExpr("$items[0]").Accept(visitor); + ParseFilterExpr(".items[0].id").Accept(visitor); + ParseFilterExpr("map(.id)").Accept(visitor); + ParseFilterExpr(".items | length").Accept(visitor); + ParseFilterExpr("(echo hi)").Accept(visitor); + ParseFilterExpr("(connect --key=abc)").Accept(visitor); + + Assert.Contains(nameof(BinaryOperatorExpression), visitor.Visited); + Assert.Contains(nameof(ConstantExpression), visitor.Visited); + Assert.Contains(nameof(UnaryOperatorExpression), visitor.Visited); + Assert.Contains(nameof(ParensExpression), visitor.Visited); + Assert.Contains(nameof(JsonArrayExpression), visitor.Visited); + Assert.Contains(nameof(JsonExpression), visitor.Visited); + Assert.Contains(nameof(InterpolatedStringExpression), visitor.Visited); + Assert.Contains(nameof(JSonPathExpression), visitor.Visited); + Assert.Contains(nameof(FilterPathExpression), visitor.Visited); + Assert.Contains(nameof(FilterCallExpression), visitor.Visited); + Assert.Contains(nameof(FilterPipeExpression), visitor.Visited); + Assert.Contains(nameof(CommandExpression), visitor.Visited); + Assert.Contains(nameof(CommandOption), visitor.Visited); + Assert.True(visitor.TokenCount > 0); + } + + [Fact] + public void Walk_DefaultVisitor_DoesNotThrow() + { + // A bare AstVisitor subclass with no overrides should traverse without error. + var visitor = new RecordingVisitor(); + + var exception = Record.Exception(() => + { + ParseFilterExpr("1 + 2 * (3 - 4)").Accept(visitor); + Accept(visitor, "if $x { echo a } else { echo b }"); + }); + + Assert.Null(exception); + } +} diff --git a/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs new file mode 100644 index 0000000..269bc9c --- /dev/null +++ b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Drives execution of and : +/// positional/variadic binding, option binding, the help short-circuit, dynamic +/// exec dispatch, and the error branches reached through CreateCommandAsync. +/// +public class CommandExecutionTests : TestBase +{ + private Statement ParseSingle(string script) + => new StatementParser(script).ParseStatements().Single(); + + private Task RunSingleAsync(string script) + => ParseSingle(script).RunAsync(Shell, new CommandState(), CancellationToken.None); + + [Fact] + public async Task Command_WithVariadicArguments_BindsAllPositional() + { + var state = await RunSingleAsync("echo hello world"); + Assert.Equal("hello world", Assert.IsType(state.Result).Text); + } + + [Fact] + public async Task Command_WithHelpOption_ShowsHelp() + { + var state = await RunSingleAsync("echo --help"); + Assert.False(state.IsError); + } + + [Fact] + public async Task Command_ValuedOptions_AreBoundBeforeExecution() + { + // settings binds --db / --con then fails on the missing connection. + await Assert.ThrowsAsync( + () => RunSingleAsync("settings --db=mydb --con=mycon")); + } + + [Fact] + public async Task Command_UnknownOption_Throws() + { + await Assert.ThrowsAsync( + () => RunSingleAsync("settings --bogus=1")); + } + + [Fact] + public async Task Command_OptionMissingValue_Throws() + { + await Assert.ThrowsAsync( + () => RunSingleAsync("settings --db")); + } + + [Fact] + public async Task Command_TooManyPositionalArguments_Throws() + { + // print declares exactly two positional parameters (id, key). + await Assert.ThrowsAsync( + () => RunSingleAsync("print a b c")); + } + + [Fact] + public async Task Command_Unknown_Throws_CommandNotFoundException() + { + await Assert.ThrowsAsync( + () => RunSingleAsync("totallyunknowncommand999 arg")); + } + + [Fact] + public async Task Exec_StringLiteralCommand_RunsIt() + { + var state = await RunSingleAsync("exec \"echo\" hi there"); + Assert.Equal("hi there", Assert.IsType(state.Result).Text); + } + + [Fact] + public async Task Exec_VariableCommand_RunsResolvedCommand() + { + SetVariable("cmd", new ShellText("echo")); + + var state = await RunSingleAsync("exec $cmd hello world"); + + Assert.Equal("hello world", Assert.IsType(state.Result).Text); + } + + [Fact] + public async Task Exec_EmptyCommandPath_Throws() + { + SetVariable("cmd", new ShellText(string.Empty)); + + await Assert.ThrowsAsync( + () => RunSingleAsync("exec $cmd")); + } + + [Fact] + public async Task Exec_FailingCommandWithScriptContext_WrapsInPositionalException() + { + // When the shell carries script context, errors raised by the dynamically + // executed command are re-thrown as PositionalException with line/column info. + Shell.CurrentScriptFileName = "script.csh"; + Shell.CurrentScriptContent = "exec $cmd"; + SetVariable("cmd", new ShellText("totallyunknowncommand999")); + + var ex = await Assert.ThrowsAsync( + () => RunSingleAsync("exec $cmd")); + Assert.Equal("script.csh", ex.FileName); + } +} diff --git a/CosmosDBShell.Tests/Parser/CommandExpressionTests.cs b/CosmosDBShell.Tests/Parser/CommandExpressionTests.cs new file mode 100644 index 0000000..731296a --- /dev/null +++ b/CosmosDBShell.Tests/Parser/CommandExpressionTests.cs @@ -0,0 +1,119 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Exercises evaluation paths: built-in command +/// execution, user-defined function dispatch, positional/variadic parameter binding, +/// option binding, and the error branches (unknown command, unknown option, missing +/// option value) reached via CreateCommandAsync. +/// +public class CommandExpressionTests : TestBase +{ + private async Task EvalCommandAsync(string input) + { + var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression(); + return await expr.EvaluateAsync(Shell, new CommandState(), CancellationToken.None); + } + + [Fact] + public async Task BuiltinCommand_InExpression_ReturnsResult() + { + var result = await EvalCommandAsync("(echo hello)"); + var text = Assert.IsType(result); + Assert.Equal("hello", text.Text); + } + + [Fact] + public async Task BuiltinCommand_WithMultipleArguments_BindsVariadicParameter() + { + var result = await EvalCommandAsync("(echo hello world)"); + var text = Assert.IsType(result); + Assert.Equal("hello world", text.Text); + } + + [Fact] + public async Task UserDefinedFunction_InExpression_IsInvoked() + { + await RunScriptAsync("def greet() { return \"hi\" }"); + + var result = await EvalCommandAsync("(greet)"); + + var text = Assert.IsType(result); + Assert.Equal("hi", text.Text); + } + + [Fact] + public async Task UserDefinedFunction_WithArgument_ReceivesArgument() + { + await RunScriptAsync("def echoback($who) { return $who }"); + + var result = await EvalCommandAsync("(echoback world)"); + + var text = Assert.IsType(result); + Assert.Equal("world", text.Text); + } + + [Fact] + public async Task UnknownCommand_Throws_CommandNotFoundException() + { + await Assert.ThrowsAsync( + () => EvalCommandAsync("(definitelynotacommand123)")); + } + + [Fact] + public async Task UnknownOption_Throws_UnknownOptionException() + { + await Assert.ThrowsAsync( + () => EvalCommandAsync("(settings --bogus=1)")); + } + + [Fact] + public async Task ValuedOption_IsBound_ThenCommandExecutes() + { + // settings binds --db / --con (exercising the valued-option branch) and then + // fails because the shell is not connected. Reaching NotConnectedException + // proves option binding completed successfully. + await Assert.ThrowsAsync( + () => EvalCommandAsync("(settings --db=mydb --con=mycon)")); + } + + [Fact] + public async Task NonBooleanOption_WithoutValue_Throws_CommandException() + { + await Assert.ThrowsAsync( + () => EvalCommandAsync("(settings --db)")); + } + + [Fact] + public async Task PositionalParameters_AreBound_ThenCommandExecutes() + { + // print binds two positional parameters (id, key) before failing on the + // missing connection; reaching NotConnectedException proves binding succeeded. + await Assert.ThrowsAsync( + () => EvalCommandAsync("(print a b)")); + } + + [Fact] + public async Task TooManyPositionalArguments_Throws_CommandException() + { + await Assert.ThrowsAsync( + () => EvalCommandAsync("(print a b c)")); + } + + [Fact] + public async Task VariadicParameter_AbsorbsAllPositionalArguments() + { + // echo's variadic Messages parameter consumes every positional argument. + var result = await EvalCommandAsync("(echo one two three)"); + Assert.Equal("one two three", Assert.IsType(result).Text); + } +} diff --git a/CosmosDBShell.Tests/Parser/ExpressionParserErrorTests.cs b/CosmosDBShell.Tests/Parser/ExpressionParserErrorTests.cs new file mode 100644 index 0000000..e639e14 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/ExpressionParserErrorTests.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Exercises the error-reporting and recovery branches of : +/// unexpected tokens, missing closing delimiters, unexpected end of input, and the +/// synthetic recovery nodes. These paths report into the +/// shared lexer error list rather than throwing. +/// +public class ExpressionParserErrorTests +{ + private static (Expression? Expr, int ErrorCount) ParseFilter(string input) + { + var lexer = new Lexer(input); + var parser = new ExpressionParser(lexer); + Expression? expr = null; + try + { + expr = parser.ParseFilterExpression(); + } + catch + { + // Some malformed inputs abort via exception; the error list is still the + // primary signal under test, so swallow and assert on errors below. + } + + return (expr, lexer.Errors.Count); + } + + [Fact] + public void MissingCloseParenthesis_ReportsError() + { + var (_, errors) = ParseFilter("(1 + 2"); + Assert.True(errors > 0); + } + + [Fact] + public void MissingCloseBracket_ReportsError() + { + var (_, errors) = ParseFilter("[1, 2"); + Assert.True(errors > 0); + } + + [Fact] + public void MissingCloseBrace_ReportsError() + { + var (_, errors) = ParseFilter("{ id: 1"); + Assert.True(errors > 0); + } + + [Fact] + public void UnexpectedClosingParenthesis_ReportsError() + { + var (_, errors) = ParseFilter(")"); + Assert.True(errors > 0); + } + + [Fact] + public void DanglingBinaryOperator_ReportsError() + { + var (_, errors) = ParseFilter("1 +"); + Assert.True(errors > 0); + } + + [Fact] + public void EmptyInput_ReportsErrorOrReturnsRecovery() + { + var (expr, errors) = ParseFilter(string.Empty); + Assert.True(errors > 0 || expr != null); + } + + [Fact] + public void WellFormedExpression_ReportsNoErrors() + { + var (expr, errors) = ParseFilter("(1 + 2) * 3"); + Assert.NotNull(expr); + Assert.Equal(0, errors); + } + + [Fact] + public void RecoveredExpression_StillReturnsNode() + { + // Even when delimiters are missing the parser returns a (partial) node so that + // tolerant consumers like syntax highlighting can keep walking. + var (expr, _) = ParseFilter("(1 + 2"); + Assert.NotNull(expr); + } +} diff --git a/CosmosDBShell.Tests/Parser/FilterBuiltinsTests.cs b/CosmosDBShell.Tests/Parser/FilterBuiltinsTests.cs new file mode 100644 index 0000000..cc86522 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/FilterBuiltinsTests.cs @@ -0,0 +1,207 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Exercises the filter built-in functions (type, length, keys, +/// contains, select, sort_by) and the supporting helpers in +/// (DescribeKind, Contains, Compare, JsonEquals) +/// across JSON value kinds and their error branches. +/// +public class FilterBuiltinsTests +{ + private static async Task EvalAsync(string input, object? value) + { + var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression(); + var state = new CommandState + { + Result = new ShellJson(JsonSerializer.SerializeToElement(value)), + }; + + return await expr.EvaluateAsync(ShellInterpreter.Instance, state, CancellationToken.None); + } + + private static string TypeName(ShellObject result) => Assert.IsType(result).Text; + + [Fact] + public async Task Type_Object_ReturnsObject() + => Assert.Equal("object", TypeName(await EvalAsync(". | type", new { a = 1 }))); + + [Fact] + public async Task Type_Array_ReturnsArray() + => Assert.Equal("array", TypeName(await EvalAsync(". | type", new[] { 1, 2 }))); + + [Fact] + public async Task Type_String_ReturnsString() + => Assert.Equal("string", TypeName(await EvalAsync(". | type", "hello"))); + + [Fact] + public async Task Type_Number_ReturnsNumber() + => Assert.Equal("number", TypeName(await EvalAsync(". | type", 42))); + + [Fact] + public async Task Type_Boolean_ReturnsBoolean() + => Assert.Equal("boolean", TypeName(await EvalAsync(". | type", true))); + + [Fact] + public async Task Type_Null_ReturnsNull() + => Assert.Equal("null", TypeName(await EvalAsync(". | type", (object?)null))); + + [Fact] + public async Task Length_Object_CountsProperties() + { + var result = await EvalAsync(". | length", new { a = 1, b = 2, c = 3 }); + Assert.Equal(3, Assert.IsType(result).Value); + } + + [Fact] + public async Task Length_String_CountsCharacters() + { + var result = await EvalAsync(". | length", "abcd"); + Assert.Equal(4, Assert.IsType(result).Value); + } + + [Fact] + public async Task Length_Null_ReturnsZero() + { + var result = await EvalAsync(". | length", (object?)null); + Assert.Equal(0, Assert.IsType(result).Value); + } + + [Fact] + public async Task Length_Number_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(". | length", 5)); + + [Fact] + public async Task Keys_Object_ReturnsSortedKeys() + { + var result = await EvalAsync(". | keys", new { banana = 1, apple = 2, cherry = 3 }); + var json = Assert.IsType(result); + var keys = json.Value.EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.Equal(new[] { "apple", "banana", "cherry" }, keys); + } + + [Fact] + public async Task Keys_Array_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(". | keys", new[] { 1, 2 })); + + [Fact] + public async Task Contains_NumberInArray_ReturnsTrue() + => Assert.True(Assert.IsType(await EvalAsync(". | contains(2)", new[] { 1, 2, 3 })).Value); + + [Fact] + public async Task Contains_NumberNotInArray_ReturnsFalse() + => Assert.False(Assert.IsType(await EvalAsync(". | contains(9)", new[] { 1, 2, 3 })).Value); + + [Fact] + public async Task Contains_Substring_ReturnsTrue() + => Assert.True(Assert.IsType(await EvalAsync(". | contains(\"ell\")", "hello")).Value); + + [Fact] + public async Task Contains_ScalarEquality_ReturnsTrue() + => Assert.True(Assert.IsType(await EvalAsync(". | contains(5)", 5)).Value); + + [Fact] + public async Task Select_FiltersArrayByPredicate() + { + var result = await EvalAsync(". | select(. > 2)", new[] { 1, 2, 3, 4 }); + var json = Assert.IsType(result); + var values = json.Value.EnumerateArray().Select(e => e.GetInt32()).ToArray(); + Assert.Equal(new[] { 3, 4 }, values); + } + + [Fact] + public async Task Select_NonArray_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(". | select(. > 2)", 5)); + + [Fact] + public async Task SortBy_Numbers_SortsAscending() + { + var result = await EvalAsync(". | sort_by(.)", new[] { 3, 1, 2 }); + var json = Assert.IsType(result); + var values = json.Value.EnumerateArray().Select(e => e.GetInt32()).ToArray(); + Assert.Equal(new[] { 1, 2, 3 }, values); + } + + [Fact] + public async Task SortBy_Strings_SortsOrdinally() + { + var result = await EvalAsync(". | sort_by(.)", new[] { "cherry", "apple", "banana" }); + var json = Assert.IsType(result); + var values = json.Value.EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.Equal(new[] { "apple", "banana", "cherry" }, values); + } + + [Fact] + public async Task SortBy_MixedKinds_DoesNotThrow() + { + // Sorting heterogeneous keys exercises the cross-kind ranking branch in Compare. + var result = await EvalAsync(". | sort_by(.)", new object?[] { 3, "a", true, null }); + var json = Assert.IsType(result); + Assert.Equal(4, json.Value.GetArrayLength()); + } + + [Fact] + public async Task SortBy_NonArray_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(". | sort_by(.)", 5)); + + [Fact] + public async Task UnknownBuiltin_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(". | bogus_function(.)", new[] { 1 })); + + [Fact] + public async Task Contains_ObjectSubset_ReturnsTrue() + { + // Object-in-object containment exercises the recursive ObjectEquals/Contains branch. + var result = await EvalAsync( + ". | contains({ id: 1 })", + new Dictionary { ["id"] = 1, ["name"] = "x" }); + Assert.True(Assert.IsType(result).Value); + } + + [Fact] + public async Task Contains_ObjectInArray_MatchesByValue() + { + // Matching an object element walks JsonEquals over objects via JsonElementComparer. + var items = new object[] + { + new Dictionary { ["id"] = 1 }, + new Dictionary { ["id"] = 2 }, + }; + var result = await EvalAsync(". | contains({ id: 2 })", items); + Assert.True(Assert.IsType(result).Value); + } + + [Fact] + public async Task SortBy_ArrayKeys_OrdersByRawText() + { + // Array keys fall through Compare's raw-text branch without throwing. + var rows = new object[] + { + new Dictionary { ["k"] = new[] { 2 } }, + new Dictionary { ["k"] = new[] { 1 } }, + }; + var result = await EvalAsync(". | sort_by(.k)", rows); + Assert.Equal(2, Assert.IsType(result).Value.GetArrayLength()); + } + + [Fact] + public async Task ArrayEquality_ViaContains_UsesSequenceComparer() + { + // Containment of an array element compares arrays element-by-element. + var items = new object[] { new[] { 1, 2 }, new[] { 3, 4 } }; + var result = await EvalAsync(". | contains([3, 4])", items); + Assert.True(Assert.IsType(result).Value); + } +} diff --git a/CosmosDBShell.Tests/Parser/FilterPathExpressionTests.cs b/CosmosDBShell.Tests/Parser/FilterPathExpressionTests.cs new file mode 100644 index 0000000..d072424 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/FilterPathExpressionTests.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Exercises navigation: property access, indexing, +/// iteration ([]), optional segments (?), missing-property null fallback, +/// out-of-range indexing, and the type-mismatch error branches for each segment kind. +/// +public class FilterPathExpressionTests +{ + private static async Task EvalAsync(string input, object? value) + { + var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression(); + var state = new CommandState + { + Result = new ShellJson(JsonSerializer.SerializeToElement(value)), + }; + + return await expr.EvaluateAsync(ShellInterpreter.Instance, state, CancellationToken.None); + } + + [Fact] + public async Task Property_ExistingKey_ReturnsValue() + { + var result = await EvalAsync(".name", new { name = "abu" }); + Assert.Equal("abu", Assert.IsType(result).Value.GetString()); + } + + [Fact] + public async Task Property_MissingKey_ReturnsNull() + { + var result = await EvalAsync(".missing", new { name = "abu" }); + Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind); + } + + [Fact] + public async Task Property_OnNonObjectWithoutOptional_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(".name", 5)); + + [Fact] + public async Task Property_OnNonObjectWithOptional_ReturnsNull() + { + var result = await EvalAsync(".name?", 5); + Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind); + } + + [Fact] + public async Task Index_InRange_ReturnsElement() + { + var result = await EvalAsync(".[1]", new[] { 10, 20, 30 }); + Assert.Equal(20, Assert.IsType(result).Value.GetInt32()); + } + + [Fact] + public async Task Index_OutOfRange_ReturnsNull() + { + var result = await EvalAsync(".[9]", new[] { 10, 20, 30 }); + Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind); + } + + [Fact] + public async Task Index_OnNonArrayWithoutOptional_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(".[0]", new { a = 1 })); + + [Fact] + public async Task Iterate_OverArray_ReturnsSequence() + { + var result = await EvalAsync(".items[]", new { items = new[] { 1, 2, 3 } }); + var sequence = Assert.IsType(result); + Assert.Equal(3, sequence.Elements.Count); + } + + [Fact] + public async Task Iterate_OnNonArrayWithoutOptional_Throws() + => await Assert.ThrowsAsync(() => EvalAsync(".[]", new { a = 1 })); + + [Fact] + public async Task NestedPath_PropertyThenIndex_ReturnsElement() + { + var result = await EvalAsync(".items[0].id", new { items = new[] { new { id = "x" } } }); + Assert.Equal("x", Assert.IsType(result).Value.GetString()); + } + + [Fact] + public async Task Property_OnStringScalarWithOptional_ReturnsNull() + { + // A string scalar is not an object; the optional marker suppresses the type error. + var result = await EvalAsync(".name?", "scalar"); + Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind); + } +} diff --git a/CosmosDBShell.Tests/Parser/JsonConstructionTests.cs b/CosmosDBShell.Tests/Parser/JsonConstructionTests.cs new file mode 100644 index 0000000..c69c2e1 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/JsonConstructionTests.cs @@ -0,0 +1,128 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Exercises JSON object construction () and array +/// construction () across every value DataType branch +/// (Json, Number, Decimal, Boolean, Text, null) plus filter-sequence flattening and +/// shorthand property capture from the current result. +/// +public class JsonConstructionTests +{ + private static async Task EvalJsonAsync(string input, object? value = null) + { + var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression(); + var state = new CommandState(); + if (value != null) + { + state.Result = new ShellJson(JsonSerializer.SerializeToElement(value)); + } + + var result = await expr.EvaluateAsync(ShellInterpreter.Instance, state, CancellationToken.None); + return Assert.IsType(result).Value; + } + + [Fact] + public async Task Array_WithNumbers_BuildsJsonArray() + { + var json = await EvalJsonAsync("[1, 2, 3]"); + Assert.Equal(JsonValueKind.Array, json.ValueKind); + Assert.Equal(new[] { 1, 2, 3 }, json.EnumerateArray().Select(e => e.GetInt32())); + } + + [Fact] + public async Task Array_WithDecimals_BuildsJsonArray() + { + var json = await EvalJsonAsync("[1.5, 2.5]"); + Assert.Equal(new[] { 1.5, 2.5 }, json.EnumerateArray().Select(e => e.GetDouble())); + } + + [Fact] + public async Task Array_WithStrings_BuildsJsonArray() + { + var json = await EvalJsonAsync("[\"a\", \"b\"]"); + Assert.Equal(new[] { "a", "b" }, json.EnumerateArray().Select(e => e.GetString())); + } + + [Fact] + public async Task Array_WithBooleans_BuildsJsonArray() + { + var json = await EvalJsonAsync("[true, false]"); + Assert.Equal(new[] { JsonValueKind.True, JsonValueKind.False }, json.EnumerateArray().Select(e => e.ValueKind)); + } + + [Fact] + public async Task Array_WithNestedArray_BuildsNestedJson() + { + var json = await EvalJsonAsync("[[1, 2], [3]]"); + Assert.Equal(2, json.GetArrayLength()); + Assert.Equal(JsonValueKind.Array, json[0].ValueKind); + } + + [Fact] + public async Task Array_Empty_BuildsEmptyJsonArray() + { + var json = await EvalJsonAsync("[]"); + Assert.Equal(JsonValueKind.Array, json.ValueKind); + Assert.Equal(0, json.GetArrayLength()); + } + + [Fact] + public async Task Array_FlattensFilterSequence() + { + var json = await EvalJsonAsync("[.items[]]", new { items = new[] { 10, 20, 30 } }); + Assert.Equal(new[] { 10, 20, 30 }, json.EnumerateArray().Select(e => e.GetInt32())); + } + + [Fact] + public async Task Object_WithMixedValueTypes_BuildsJsonObject() + { + var json = await EvalJsonAsync("{ n: 1, d: 2.5, s: \"hi\", b: true }"); + Assert.Equal(JsonValueKind.Object, json.ValueKind); + Assert.Equal(1, json.GetProperty("n").GetInt32()); + Assert.Equal(2.5, json.GetProperty("d").GetDouble()); + Assert.Equal("hi", json.GetProperty("s").GetString()); + Assert.Equal(JsonValueKind.True, json.GetProperty("b").ValueKind); + } + + [Fact] + public async Task Object_WithNullValue_SerializesNull() + { + var json = await EvalJsonAsync("{ x: null }"); + Assert.Equal(JsonValueKind.Null, json.GetProperty("x").ValueKind); + } + + [Fact] + public async Task Object_WithNestedObject_BuildsNestedJson() + { + var json = await EvalJsonAsync("{ outer: { inner: 1 } }"); + Assert.Equal(1, json.GetProperty("outer").GetProperty("inner").GetInt32()); + } + + [Fact] + public async Task Object_ShorthandProperties_CaptureFromCurrentResult() + { + var json = await EvalJsonAsync("{ id, status }", new { id = "1", status = "active", extra = "ignored" }); + Assert.Equal("1", json.GetProperty("id").GetString()); + Assert.Equal("active", json.GetProperty("status").GetString()); + Assert.False(json.TryGetProperty("extra", out _)); + } + + [Fact] + public async Task Object_PropertyFromJsonPath_BuildsJsonObject() + { + var json = await EvalJsonAsync("{ name: .title }", new { title = "Volcano" }); + Assert.Equal("Volcano", json.GetProperty("name").GetString()); + } +} diff --git a/CosmosDBShell.Tests/Parser/OperatorEvaluationTests.cs b/CosmosDBShell.Tests/Parser/OperatorEvaluationTests.cs new file mode 100644 index 0000000..b5242f6 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/OperatorEvaluationTests.cs @@ -0,0 +1,185 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Evaluates binary and unary operator expressions to exercise the arithmetic, +/// comparison, logical, and type-coercion branches in +/// and . +/// +public class OperatorEvaluationTests +{ + private static async Task EvalAsync(string input) + { + var expression = new ExpressionParser(new Lexer(input)).ParseFilterExpression(); + return await expression.EvaluateAsync(ShellInterpreter.Instance, new CommandState(), CancellationToken.None); + } + + [Theory] + [InlineData("1 + 2", 3)] + [InlineData("5 - 3", 2)] + [InlineData("4 * 3", 12)] + [InlineData("10 / 2", 5)] + [InlineData("10 % 3", 1)] + [InlineData("2 ** 3", 8)] + public async Task IntegerArithmetic_ReturnsExpectedNumber(string input, int expected) + { + var result = await EvalAsync(input); + var number = Assert.IsType(result); + Assert.Equal(expected, number.Value); + } + + [Theory] + [InlineData("1.5 + 2.5", 4.0)] + [InlineData("5.0 - 2.5", 2.5)] + [InlineData("2.5 * 2.0", 5.0)] + [InlineData("5.0 / 2.0", 2.5)] + [InlineData("5.5 % 2.0", 1.5)] + [InlineData("2.0 ** 3.0", 8.0)] + public async Task DecimalArithmetic_ReturnsExpectedDecimal(string input, double expected) + { + var result = await EvalAsync(input); + var dec = Assert.IsType(result); + Assert.Equal(expected, dec.Value); + } + + [Theory] + [InlineData("\"a\" + \"b\"", "ab")] + [InlineData("\"n=\" + 5", "n=5")] + [InlineData("5 + \"px\"", "5px")] + public async Task StringConcatenation_ReturnsText(string input, string expected) + { + var result = await EvalAsync(input); + var text = Assert.IsType(result); + Assert.Equal(expected, text.Text); + } + + [Fact] + public async Task ArrayConcatenation_MergesJsonArrays() + { + var result = await EvalAsync("[1, 2] + [3]"); + var json = Assert.IsType(result); + Assert.Equal(JsonValueKind.Array, json.Value.ValueKind); + Assert.Equal(3, json.Value.GetArrayLength()); + } + + [Theory] + [InlineData("1 == 1", true)] + [InlineData("1 == 2", false)] + [InlineData("1 != 2", true)] + [InlineData("1 != 1", false)] + [InlineData("1 < 2", true)] + [InlineData("2 < 1", false)] + [InlineData("2 > 1", true)] + [InlineData("1 <= 1", true)] + [InlineData("2 <= 1", false)] + [InlineData("2 >= 2", true)] + [InlineData("2 >= 3", false)] + public async Task IntegerComparison_ReturnsExpectedBool(string input, bool expected) + { + var result = await EvalAsync(input); + var boolean = Assert.IsType(result); + Assert.Equal(expected, boolean.Value); + } + + [Theory] + [InlineData("1.5 == 1.5", true)] + [InlineData("1.5 < 2.5", true)] + [InlineData("2.5 > 1.5", true)] + [InlineData("2.5 <= 2.5", true)] + [InlineData("2.5 >= 3.5", false)] + public async Task DecimalComparison_ReturnsExpectedBool(string input, bool expected) + { + var result = await EvalAsync(input); + var boolean = Assert.IsType(result); + Assert.Equal(expected, boolean.Value); + } + + [Theory] + [InlineData("true == true", true)] + [InlineData("true == false", false)] + [InlineData("\"a\" == \"a\"", true)] + [InlineData("\"a\" == \"b\"", false)] + [InlineData("1 == \"1\"", true)] + public async Task EqualityAcrossTypes_ReturnsExpectedBool(string input, bool expected) + { + var result = await EvalAsync(input); + var boolean = Assert.IsType(result); + Assert.Equal(expected, boolean.Value); + } + + [Theory] + [InlineData("true && true", true)] + [InlineData("true && false", false)] + [InlineData("false || true", true)] + [InlineData("false || false", false)] + [InlineData("true ^ false", true)] + [InlineData("true ^ true", false)] + public async Task LogicalOperators_ReturnExpectedBool(string input, bool expected) + { + var result = await EvalAsync(input); + var boolean = Assert.IsType(result); + Assert.Equal(expected, boolean.Value); + } + + [Fact] + public async Task LogicalAnd_ShortCircuits_DoesNotEvaluateRight() + { + // The right side would divide by zero; short-circuit must avoid evaluating it. + var result = await EvalAsync("false && (10 / 0 == 0)"); + Assert.False(Assert.IsType(result).Value); + } + + [Fact] + public async Task LogicalOr_ShortCircuits_DoesNotEvaluateRight() + { + var result = await EvalAsync("true || (10 / 0 == 0)"); + Assert.True(Assert.IsType(result).Value); + } + + [Theory] + [InlineData("10 / 0")] + [InlineData("10.0 / 0.0")] + [InlineData("10 % 0")] + [InlineData("10.0 % 0.0")] + public async Task DivisionOrModuloByZero_Throws(string input) + { + await Assert.ThrowsAsync(() => EvalAsync(input)); + } + + [Theory] + [InlineData("!true", false)] + [InlineData("!false", true)] + public async Task UnaryNot_NegatesBoolean(string input, bool expected) + { + var result = await EvalAsync(input); + Assert.Equal(expected, Assert.IsType(result).Value); + } + + [Theory] + [InlineData("-5", -5)] + [InlineData("+5", 5)] + public async Task UnaryNumeric_ReturnsNumber(string input, int expected) + { + var result = await EvalAsync(input); + Assert.Equal(expected, Assert.IsType(result).Value); + } + + [Theory] + [InlineData("-2.5", -2.5)] + [InlineData("+2.5", 2.5)] + public async Task UnaryNumeric_ReturnsDecimal(string input, double expected) + { + var result = await EvalAsync(input); + Assert.Equal(expected, Assert.IsType(result).Value); + } +} diff --git a/CosmosDBShell.Tests/Parser/PositionalErrorTests.cs b/CosmosDBShell.Tests/Parser/PositionalErrorTests.cs new file mode 100644 index 0000000..97f9aee --- /dev/null +++ b/CosmosDBShell.Tests/Parser/PositionalErrorTests.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System; + +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Direct unit tests for (line/column +/// resolution from a flat character offset) and the +/// carrier used to surface script-relative error positions. +/// +public class PositionalErrorTests +{ + [Fact] + public void GetLineAndColumn_FirstCharacter_IsLineOneColumnOne() + { + var (line, column, lineText) = PositionalErrorHelper.GetLineAndColumn("hello world", 0); + Assert.Equal(1, line); + Assert.Equal(1, column); + Assert.Equal("hello world", lineText); + } + + [Fact] + public void GetLineAndColumn_MidFirstLine_TracksColumn() + { + var (line, column, _) = PositionalErrorHelper.GetLineAndColumn("hello world", 6); + Assert.Equal(1, line); + Assert.Equal(7, column); + } + + [Fact] + public void GetLineAndColumn_SecondLine_TracksLineAndColumn() + { + var text = "first\nsecond\nthird"; + var position = text.IndexOf("second", StringComparison.Ordinal) + 2; + var (line, column, lineText) = PositionalErrorHelper.GetLineAndColumn(text, position); + Assert.Equal(2, line); + Assert.Equal(3, column); + Assert.Equal("second", lineText); + } + + [Fact] + public void GetLineAndColumn_LastLineWithoutTrailingNewline_ReturnsRemainder() + { + var text = "a\nb\nlast line"; + var (line, _, lineText) = PositionalErrorHelper.GetLineAndColumn(text, text.Length); + Assert.Equal(3, line); + Assert.Equal("last line", lineText); + } + + [Fact] + public void GetLineAndColumn_TrimsCarriageReturn() + { + var text = "windows\r\nline"; + var (line, _, lineText) = PositionalErrorHelper.GetLineAndColumn(text, 0); + Assert.Equal(1, line); + Assert.Equal("windows", lineText); + } + + [Fact] + public void GetLineAndColumn_PositionBeyondLength_ClampsToEnd() + { + var text = "short"; + var (line, _, lineText) = PositionalErrorHelper.GetLineAndColumn(text, 1000); + Assert.Equal(1, line); + Assert.Equal("short", lineText); + } + + [Fact] + public void PositionalException_ExposesPositionAndInnerMessage() + { + var inner = new InvalidOperationException("boom"); + var ex = new PositionalException("script.csh", inner, line: 4, column: 7, lineText: "echo bad"); + + Assert.Equal("script.csh", ex.FileName); + Assert.Equal(4, ex.Line); + Assert.Equal(7, ex.Column); + Assert.Equal("echo bad", ex.LineText); + Assert.Equal("boom", ex.Message); + Assert.Same(inner, ex.InnerException); + } + + [Fact] + public void PositionalException_AllowsNullLineText() + { + var ex = new PositionalException("f.csh", new Exception("x"), 1, 1); + Assert.Null(ex.LineText); + } +} diff --git a/CosmosDBShell.Tests/Parser/ShellObjectConversionTests.cs b/CosmosDBShell.Tests/Parser/ShellObjectConversionTests.cs new file mode 100644 index 0000000..18ed743 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/ShellObjectConversionTests.cs @@ -0,0 +1,205 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Conversion tests for the value types +/// (, , ). +/// These are pure functions that map a value to each , including +/// the error branches for unconvertible values. +/// +public class ShellObjectConversionTests +{ + private static ShellJson Json(string raw) + { + using var doc = JsonDocument.Parse(raw); + return new ShellJson(doc.RootElement.Clone()); + } + + [Fact] + public void ShellJson_NumberToAllTypes() + { + var json = Json("42"); + Assert.Equal("42", json.ConvertShellObject(DataType.Text)); + Assert.Equal(42, json.ConvertShellObject(DataType.Number)); + Assert.Equal(42m, json.ConvertShellObject(DataType.Decimal)); + Assert.Equal(true, json.ConvertShellObject(DataType.Boolean)); + } + + [Fact] + public void ShellJson_ZeroNumber_IsFalse() + { + Assert.Equal(false, Json("0").ConvertShellObject(DataType.Boolean)); + } + + [Fact] + public void ShellJson_StringValue_TextReturnsUnquoted() + { + var json = Json("\"hello\""); + Assert.Equal("hello", json.ConvertShellObject(DataType.Text)); + } + + [Theory] + [InlineData("\"true\"", true)] + [InlineData("\"1\"", true)] + [InlineData("\"yes\"", true)] + [InlineData("\"false\"", false)] + [InlineData("\"0\"", false)] + [InlineData("\"no\"", false)] + [InlineData("\"\"", false)] + public void ShellJson_StringToBoolean(string raw, bool expected) + { + Assert.Equal(expected, Json(raw).ConvertShellObject(DataType.Boolean)); + } + + [Theory] + [InlineData("\"123\"", 123)] + public void ShellJson_StringToNumber(string raw, int expected) + { + Assert.Equal(expected, Json(raw).ConvertShellObject(DataType.Number)); + } + + [Fact] + public void ShellJson_StringToDecimal() + { + Assert.Equal(15m, Json("\"15\"").ConvertShellObject(DataType.Decimal)); + } + + [Fact] + public void ShellJson_TrueFalseLiterals_ToBoolean() + { + Assert.Equal(true, Json("true").ConvertShellObject(DataType.Boolean)); + Assert.Equal(false, Json("false").ConvertShellObject(DataType.Boolean)); + } + + [Fact] + public void ShellJson_ObjectToText_ReturnsRawJson() + { + var json = Json("{\"id\":\"1\"}"); + var text = Assert.IsType(json.ConvertShellObject(DataType.Text)); + Assert.Contains("\"id\"", text); + } + + [Fact] + public void ShellJson_JsonToJson_ReturnsElement() + { + var json = Json("[1,2,3]"); + var element = Assert.IsType(json.ConvertShellObject(DataType.Json)); + Assert.Equal(JsonValueKind.Array, element.ValueKind); + } + + [Fact] + public void ShellJson_NonNumericStringToNumber_Throws() + { + Assert.Throws(() => Json("\"abc\"").ConvertShellObject(DataType.Number)); + } + + [Fact] + public void ShellJson_ObjectToBoolean_Throws() + { + Assert.Throws(() => Json("{}").ConvertShellObject(DataType.Boolean)); + } + + [Fact] + public void ShellJson_ObjectToDecimal_Throws() + { + Assert.Throws(() => Json("{}").ConvertShellObject(DataType.Decimal)); + } + + [Fact] + public void ShellIdentifier_Text_ReturnsValue() + { + Assert.Equal("abc", new ShellIdentifier("abc").ConvertShellObject(DataType.Text)); + } + + [Theory] + [InlineData("true", true)] + [InlineData("1", true)] + [InlineData("yes", true)] + [InlineData("false", false)] + [InlineData("0", false)] + [InlineData("no", false)] + [InlineData("", false)] + public void ShellIdentifier_ToBoolean(string value, bool expected) + { + Assert.Equal(expected, new ShellIdentifier(value).ConvertShellObject(DataType.Boolean)); + } + + [Fact] + public void ShellIdentifier_ToNumber() + { + Assert.Equal(7, new ShellIdentifier("7").ConvertShellObject(DataType.Number)); + } + + [Fact] + public void ShellIdentifier_ToDecimal() + { + Assert.Equal(25d, new ShellIdentifier("25").ConvertShellObject(DataType.Decimal)); + } + + [Fact] + public void ShellIdentifier_ToJson_ParsesValue() + { + var element = Assert.IsType(new ShellIdentifier("{\"a\":1}").ConvertShellObject(DataType.Json)); + Assert.Equal(1, element.GetProperty("a").GetInt32()); + } + + [Fact] + public void ShellIdentifier_InvalidBoolean_Throws() + { + Assert.Throws(() => new ShellIdentifier("maybe").ConvertShellObject(DataType.Boolean)); + } + + [Fact] + public void ShellIdentifier_InvalidNumber_Throws() + { + Assert.Throws(() => new ShellIdentifier("abc").ConvertShellObject(DataType.Number)); + } + + [Fact] + public void ShellIdentifier_InvalidDecimal_Throws() + { + Assert.Throws(() => new ShellIdentifier("abc").ConvertShellObject(DataType.Decimal)); + } + + [Fact] + public void ShellIdentifier_InvalidJson_Throws() + { + Assert.Throws(() => new ShellIdentifier("{not json").ConvertShellObject(DataType.Json)); + } + + [Fact] + public void ShellSequence_ToJson_ReturnsArray() + { + var seq = SequenceOf("1", "2", "3"); + var element = Assert.IsType(seq.ConvertShellObject(DataType.Json)); + Assert.Equal(JsonValueKind.Array, element.ValueKind); + Assert.Equal(3, element.GetArrayLength()); + } + + [Fact] + public void ShellSequence_ToText_ReturnsRawArrayJson() + { + var seq = SequenceOf("1", "2"); + var text = Assert.IsType(seq.ConvertShellObject(DataType.Text)); + Assert.StartsWith("[", text); + Assert.EndsWith("]", text); + } + + private static ShellSequence SequenceOf(params string[] rawElements) + { + var elements = new List(); + foreach (var raw in rawElements) + { + using var doc = JsonDocument.Parse(raw); + elements.Add(doc.RootElement.Clone()); + } + + return new ShellSequence(elements); + } +} diff --git a/CosmosDBShell.Tests/Parser/ShellObjectParseTests.cs b/CosmosDBShell.Tests/Parser/ShellObjectParseTests.cs new file mode 100644 index 0000000..c2a7b73 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/ShellObjectParseTests.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Exercises , which maps the first lexer token +/// to a concrete subtype (boolean, number, identifier, text). +/// +public class ShellObjectParseTests +{ + private static ShellObject Parse(string input) => ShellObject.Parse(new Lexer(input)); + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("True")] + public void Parse_TrueLiteral_ReturnsBoolTrue(string input) + { + var result = Parse(input); + Assert.True(Assert.IsType(result).Value); + } + + [Theory] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("False")] + public void Parse_FalseLiteral_ReturnsBoolFalse(string input) + { + var result = Parse(input); + Assert.False(Assert.IsType(result).Value); + } + + [Theory] + [InlineData("0", 0)] + [InlineData("42", 42)] + public void Parse_NumericToken_ReturnsNumber(string input, int expected) + { + var result = Parse(input); + Assert.Equal(expected, Assert.IsType(result).Value); + } + + [Fact] + public void Parse_DecimalToken_ReturnsDecimal() + { + var result = Parse("1.5"); + Assert.Equal(1.5, Assert.IsType(result).Value); + } + + [Fact] + public void Parse_BareWord_ReturnsIdentifier() + { + var result = Parse("hello"); + Assert.Equal("hello", Assert.IsType(result).Value); + } + + [Fact] + public void Parse_QuotedString_ReturnsText() + { + var result = Parse("\"quoted value\""); + Assert.Equal("quoted value", Assert.IsType(result).Text); + } +} diff --git a/CosmosDBShell.Tests/Parser/StatementExecutionTests.cs b/CosmosDBShell.Tests/Parser/StatementExecutionTests.cs new file mode 100644 index 0000000..3f01962 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/StatementExecutionTests.cs @@ -0,0 +1,187 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Executes whole scripts to drive the RunAsync branches of control-flow +/// statements (if/else, while, do-while, for, loop, break, continue, return, blocks) +/// together with the condition-evaluation paths in the expression parser. +/// +public class StatementExecutionTests : TestBase +{ + private int GetInt(string name) + { + var value = GetVariable(name); + return (int)Assert.IsType(value).Value; + } + + [Fact] + public async Task If_TrueCondition_ExecutesThenBranch() + { + var state = await RunScriptAsync("$x = 0\nif 1 < 2 { $x = 10 } else { $x = 20 }"); + Assert.False(state.IsError); + Assert.Equal(10, GetInt("x")); + } + + [Fact] + public async Task If_FalseCondition_ExecutesElseBranch() + { + var state = await RunScriptAsync("$x = 0\nif 1 > 2 { $x = 10 } else { $x = 20 }"); + Assert.False(state.IsError); + Assert.Equal(20, GetInt("x")); + } + + [Fact] + public async Task If_FalseCondition_NoElse_DoesNothing() + { + var state = await RunScriptAsync("$x = 5\nif false { $x = 99 }"); + Assert.False(state.IsError); + Assert.Equal(5, GetInt("x")); + } + + [Fact] + public async Task While_Counts_UntilConditionFalse() + { + var state = await RunScriptAsync("$i = 0\nwhile $i < 3 { $i = ($i + 1) }"); + Assert.False(state.IsError); + Assert.Equal(3, GetInt("i")); + } + + [Fact] + public async Task While_Break_ExitsEarly() + { + var state = await RunScriptAsync("$i = 0\nwhile $i < 100 { $i = ($i + 1)\nif $i == 5 break }"); + Assert.False(state.IsError); + Assert.Equal(5, GetInt("i")); + } + + [Fact] + public async Task While_Continue_SkipsRemainderOfBody() + { + var script = "$i = 0\n$count = 0\nwhile $i < 5 { $i = ($i + 1)\nif $i == 3 continue\n$count = ($count + 1) }"; + var state = await RunScriptAsync(script); + Assert.False(state.IsError); + Assert.Equal(5, GetInt("i")); + Assert.Equal(4, GetInt("count")); + } + + [Fact] + public async Task DoWhile_ExecutesBodyAtLeastOnce_WhenConditionFalse() + { + var state = await RunScriptAsync("$x = 0\ndo { $x = ($x + 1) } while $x < 0"); + Assert.False(state.IsError); + Assert.Equal(1, GetInt("x")); + } + + [Fact] + public async Task For_OverArray_AccumulatesValues() + { + var state = await RunScriptAsync("$sum = 0\nfor $i in [1, 2, 3, 4] { $sum = ($sum + $i) }"); + Assert.False(state.IsError); + Assert.Equal(10, GetInt("sum")); + } + + [Fact] + public async Task For_Break_StopsIteration() + { + var state = await RunScriptAsync("$sum = 0\nfor $i in [1, 2, 3, 4] { if $i == 3 break\n$sum = ($sum + $i) }"); + Assert.False(state.IsError); + Assert.Equal(3, GetInt("sum")); + } + + [Fact] + public async Task For_Continue_SkipsSelectedIterations() + { + var state = await RunScriptAsync("$sum = 0\nfor $i in [1, 2, 3, 4] { if $i == 2 continue\n$sum = ($sum + $i) }"); + Assert.False(state.IsError); + Assert.Equal(8, GetInt("sum")); + } + + [Fact] + public async Task Loop_Break_TerminatesWithCounter() + { + var state = await RunScriptAsync("$i = 0\nloop { $i = ($i + 1)\nif $i == 7 break }"); + Assert.False(state.IsError); + Assert.Equal(7, GetInt("i")); + } + + [Fact] + public async Task NestedControlFlow_ComputesExpected() + { + var script = "$total = 0\nfor $i in [1, 2, 3] { $j = 0\nwhile $j < $i { $total = ($total + 1)\n$j = ($j + 1) } }"; + var state = await RunScriptAsync(script); + Assert.False(state.IsError); + Assert.Equal(6, GetInt("total")); + } + + [Fact] + public async Task Block_ExecutesStatementsSequentially() + { + var state = await RunScriptAsync("{ $a = 1\n$b = ($a + 1)\n$c = ($b + 1) }"); + Assert.False(state.IsError); + Assert.Equal(1, GetInt("a")); + Assert.Equal(2, GetInt("b")); + Assert.Equal(3, GetInt("c")); + } + + [Fact] + public async Task Assignment_ChainedArithmetic_ComputesExpected() + { + var state = await RunScriptAsync("$x = ((2 + 3) * 4 - 1)"); + Assert.False(state.IsError); + Assert.Equal(19, GetInt("x")); + } + + [Fact] + public async Task For_OverStrings_BindsTextElements() + { + var state = await RunScriptAsync("for $x in [\"a\", \"b\"] { }"); + Assert.False(state.IsError); + Assert.Equal("b", Assert.IsType(GetVariable("x")).Text); + } + + [Fact] + public async Task For_OverBooleans_BindsBoolElements() + { + var state = await RunScriptAsync("for $x in [true, false] { }"); + Assert.False(state.IsError); + Assert.False(Assert.IsType(GetVariable("x")).Value); + } + + [Fact] + public async Task For_OverNull_BindsNullAsText() + { + var state = await RunScriptAsync("for $x in [null] { }"); + Assert.False(state.IsError); + Assert.Equal("null", Assert.IsType(GetVariable("x")).Text); + } + + [Fact] + public async Task For_OverObjects_BindsJsonElements() + { + var state = await RunScriptAsync("for $x in [{ id: 1 }] { }"); + Assert.False(state.IsError); + Assert.IsType(GetVariable("x")); + } + + [Fact] + public async Task For_OverNestedArrays_BindsJsonElements() + { + var state = await RunScriptAsync("for $x in [[1, 2], [3, 4]] { }"); + Assert.False(state.IsError); + Assert.IsType(GetVariable("x")); + } + + [Fact] + public async Task For_OverNonArray_Throws() + { + await Assert.ThrowsAsync( + () => RunScriptAsync("for $x in 5 { }")); + } +} diff --git a/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs b/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs new file mode 100644 index 0000000..9736af1 --- /dev/null +++ b/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs @@ -0,0 +1,136 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Collections.Generic; + +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Covers structural parsing and error-recovery paths in +/// that are not exercised by the happy-path statement tests: pipes, exec, function +/// parameter lists, tolerant partial parsing, and recoverable syntax errors. +/// +public class StatementParserEdgeTests +{ + private static List Parse(string input) + { + return new StatementParser(input).ParseStatements(); + } + + private static (List Statements, StatementParser Parser) ParseWithParser(string input) + { + var parser = new StatementParser(input); + return (parser.ParseStatements(), parser); + } + + [Fact] + public void Pipe_TwoCommands_ProducesPipeStatement() + { + var statements = Parse("echo a | echo b"); + var pipe = Assert.IsType(Assert.Single(statements)); + Assert.Equal(2, pipe.Statements.Count); + } + + [Fact] + public void Pipe_ThreeCommands_ProducesPipeStatementWithThreeSegments() + { + var statements = Parse("echo a | echo b | echo c"); + var pipe = Assert.IsType(Assert.Single(statements)); + Assert.Equal(3, pipe.Statements.Count); + } + + [Fact] + public void Exec_WithArguments_ProducesExecStatement() + { + var statements = Parse("exec echo hi there"); + Assert.IsType(Assert.Single(statements)); + } + + [Fact] + public void Def_WithParenParameters_ParsesParameters() + { + var statements = Parse("def add($a, $b) { return ($a + $b) }"); + var def = Assert.IsType(Assert.Single(statements)); + Assert.Equal("add", def.Name); + Assert.Equal(new[] { "a", "b" }, def.Parameters); + } + + [Fact] + public void Def_WithBracketParameters_ParsesParameters() + { + var statements = Parse("def greet [name] { echo $name }"); + var def = Assert.IsType(Assert.Single(statements)); + Assert.Equal("greet", def.Name); + Assert.Equal(new[] { "name" }, def.Parameters); + } + + [Fact] + public void Def_WithoutParameters_ParsesEmptyParameterList() + { + var statements = Parse("def now() { echo hi }"); + var def = Assert.IsType(Assert.Single(statements)); + Assert.Empty(def.Parameters); + } + + [Fact] + public void MultipleStatements_SeparatedBySemicolons_AreAllParsed() + { + var statements = Parse("$x = 1; $y = 2; $z = 3"); + Assert.Equal(3, statements.Count); + Assert.All(statements, s => Assert.IsType(s)); + } + + [Fact] + public void Comment_IsRecorded_AndDoesNotBecomeStatement() + { + var (statements, parser) = ParseWithParser("echo a # trailing comment\necho b"); + Assert.Equal(2, statements.Count); + Assert.NotEmpty(parser.Comments); + } + + [Fact] + public void EmptyInput_ProducesNoStatements() + { + Assert.Empty(Parse(" \n \t ")); + } + + [Fact] + public void UnexpectedCloseBrace_ReportsErrorAndDoesNotThrow() + { + var (_, parser) = ParseWithParser("}"); + Assert.NotEmpty(parser.Errors); + } + + [Fact] + public void IncompleteBlock_WithoutToleration_ReportsErrorAndDropsStatement() + { + var (statements, parser) = ParseWithParser("{ echo a"); + Assert.Empty(statements); + Assert.NotEmpty(parser.Errors); + } + + [Fact] + public void IncompleteBlock_WithToleration_ReturnsPartialBlock() + { + var parser = new StatementParser("{ echo a") + { + TolerateIncompleteConstructs = true, + }; + + var statements = parser.ParseStatements(); + + var block = Assert.IsType(Assert.Single(statements)); + Assert.Single(block.Statements); + Assert.NotEmpty(parser.Errors); + } + + [Fact] + public void Pipe_WithMissingRightHandSide_ReportsError() + { + var (_, parser) = ParseWithParser("echo a |"); + Assert.NotEmpty(parser.Errors); + } +} diff --git a/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs b/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs new file mode 100644 index 0000000..ff5730c --- /dev/null +++ b/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Collections.Generic; +using System.Linq; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Covers the append-redirect synthesis (>>, 2>>), duplicate and +/// missing-destination redirect error branches, option :/=/-- value +/// syntax, and JSON-path variable parsing ($x.name, $items[0], $.name) +/// in / . +/// +public class StatementParserStructureTests +{ + private static CommandStatement ParseCommand(string input) + { + var statement = new StatementParser(input).ParseStatements().Single(); + return Assert.IsType(statement); + } + + private static (List Statements, IReadOnlyList Errors) ParseWithErrors(string input) + { + var lexer = new Lexer(input); + var parser = new StatementParser(lexer); + return (parser.ParseStatements(), lexer.Errors); + } + + [Fact] + public void AppendOutputRedirect_IsRecognized() + { + var cmd = ParseCommand("query \"SELECT * FROM c\" >> results.json"); + Assert.True(cmd.AppendOutput); + Assert.Equal("results.json", cmd.OutputRedirect); + } + + [Fact] + public void AppendErrorRedirect_IsRecognized() + { + var cmd = ParseCommand("query \"SELECT * FROM c\" 2>> errors.log"); + Assert.True(cmd.AppendError); + Assert.Equal("errors.log", cmd.ErrorRedirect); + } + + [Fact] + public void DuplicateOutputRedirect_ReportsError() + { + var (_, errors) = ParseWithErrors("query \"q\" > a.json > b.json"); + Assert.NotEmpty(errors); + } + + [Fact] + public void DuplicateErrorRedirect_ReportsError() + { + var (_, errors) = ParseWithErrors("query \"q\" 2> a.log 2> b.log"); + Assert.NotEmpty(errors); + } + + [Fact] + public void OutputRedirect_MissingDestination_ReportsError() + { + var (_, errors) = ParseWithErrors("query \"q\" >"); + Assert.NotEmpty(errors); + } + + [Fact] + public void Option_WithColonValue_ParsesValue() + { + var cmd = ParseCommand("query \"q\" -max:10"); + var option = cmd.Arguments.OfType().Single(); + Assert.Equal("max", option.Name); + Assert.Equal("10", option.Value?.ToString()); + } + + [Fact] + public void Option_WithEqualsValue_ParsesValue() + { + var cmd = ParseCommand("query \"q\" --max=25"); + var option = cmd.Arguments.OfType().Single(); + Assert.Equal("max", option.Name); + Assert.Equal("25", option.Value?.ToString()); + } + + [Fact] + public void Variable_WithPropertyAccess_ParsesAsJsonPath() + { + var expr = new ExpressionParser(new Lexer("$script.name")).ParseExpression(); + Assert.IsType(expr); + } + + [Fact] + public void Variable_WithArrayAccess_ParsesAsJsonPath() + { + var expr = new ExpressionParser(new Lexer("$items[0]")).ParseExpression(); + Assert.IsType(expr); + } + + [Fact] + public void PipedResultPath_ParsesAsJsonPath() + { + var expr = new ExpressionParser(new Lexer("$.name")).ParseExpression(); + Assert.IsType(expr); + } + + [Fact] + public void PlainVariable_ParsesAsVariableExpression() + { + var expr = new ExpressionParser(new Lexer("$foo")).ParseExpression(); + var variable = Assert.IsType(expr); + Assert.Equal("foo", variable.Name); + } +} diff --git a/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs new file mode 100644 index 0000000..0378f0b --- /dev/null +++ b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Parser; + +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; + +/// +/// Drives the error-wrapping branches in the loop statements: when a loop body throws +/// and the shell carries script context, the exception is re-thrown as a +/// . Nested loops also exercise the +/// catch (PositionalException) re-throw branch. +/// +public class StatementPositionalErrorTests : TestBase +{ + private async Task RunWithScriptContextAsync(string script) + { + Shell.CurrentScriptFileName = "script.csh"; + Shell.CurrentScriptContent = script; + + var statements = new StatementParser(script).ParseStatements(); + var state = new CommandState(); + foreach (var statement in statements) + { + state = await statement.RunAsync(Shell, state, CancellationToken.None); + } + } + + [Fact] + public async Task While_BodyThrows_WithScriptContext_WrapsInPositionalException() + { + var ex = await Assert.ThrowsAsync( + () => RunWithScriptContextAsync("while true { totallyunknowncmd999 }")); + Assert.Equal("script.csh", ex.FileName); + } + + [Fact] + public async Task DoWhile_BodyThrows_WithScriptContext_WrapsInPositionalException() + { + var ex = await Assert.ThrowsAsync( + () => RunWithScriptContextAsync("do { totallyunknowncmd999 } while false")); + Assert.Equal("script.csh", ex.FileName); + } + + [Fact] + public async Task Loop_BodyThrows_WithScriptContext_WrapsInPositionalException() + { + var ex = await Assert.ThrowsAsync( + () => RunWithScriptContextAsync("loop { totallyunknowncmd999 }")); + Assert.Equal("script.csh", ex.FileName); + } + + [Fact] + public async Task For_BodyThrows_WithScriptContext_WrapsInPositionalException() + { + var ex = await Assert.ThrowsAsync( + () => RunWithScriptContextAsync("for $x in [1] { totallyunknowncmd999 }")); + Assert.Equal("script.csh", ex.FileName); + } + + [Fact] + public async Task NestedLoops_InnerWraps_OuterRethrowsPositionalException() + { + // The inner loop wraps the failure into a PositionalException; the outer loop's + // catch (PositionalException) branch re-throws it unchanged. + var ex = await Assert.ThrowsAsync( + () => RunWithScriptContextAsync( + "for $x in [1] { for $y in [1] { totallyunknowncmd999 } }")); + Assert.Equal("script.csh", ex.FileName); + } +} diff --git a/CosmosDBShell.Tests/ResourceOperationsTests.cs b/CosmosDBShell.Tests/ResourceOperationsTests.cs new file mode 100644 index 0000000..b432e60 --- /dev/null +++ b/CosmosDBShell.Tests/ResourceOperationsTests.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using Azure.Data.Cosmos.Shell.Mcp; + +public class ResourceOperationsTests +{ + [Fact] + public void GetScriptingGuide_ReturnsEmbeddedProgrammingMarkdown() + { + var guide = ResourceOperations.GetScriptingGuide(); + + Assert.False(string.IsNullOrWhiteSpace(guide)); + Assert.Contains("#", guide); + } + + [Fact] + public void GetQueryLanguageReference_ReturnsEmbeddedQueryLanguageMarkdown() + { + var reference = ResourceOperations.GetQueryLanguageReference(); + + Assert.False(string.IsNullOrWhiteSpace(reference)); + Assert.Contains("SELECT", reference, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetScriptingGuide_IsStableAcrossCalls() + { + Assert.Equal(ResourceOperations.GetScriptingGuide(), ResourceOperations.GetScriptingGuide()); + } +} diff --git a/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs index 499b822..4767da1 100644 --- a/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs +++ b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs @@ -600,10 +600,99 @@ public void ReverseSearch_FormatSearchPromptMarkup_TruncatesLongQueryToFitMaxWid Assert.Contains("...", text); } + [Fact] + public void ClearCurrentLine_RemovesAllContent() + { + var context = CreateContext("hello world", position: 5); + + new ClearCurrentLineCommand().Execute(context); + + Assert.Equal(string.Empty, context.Buffer.Content); + Assert.Equal(0, context.Buffer.Position); + } + + [Fact] + public void ClearCurrentLine_EmptyBuffer_NoOp() + { + var context = CreateContext(string.Empty, position: 0); + + new ClearCurrentLineCommand().Execute(context); + + Assert.Equal(string.Empty, context.Buffer.Content); + Assert.Equal(0, context.Buffer.Position); + } + + [Fact] + public void ClearScreen_DoesNotThrowAndPreservesBuffer() + { + var context = CreateContext("hello", position: 2); + + var saved = AnsiConsole.Console; + try + { + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter()), + }); + + var ex = Record.Exception(() => new ClearScreenCommand().Execute(context)); + + Assert.Null(ex); + Assert.Equal("hello", context.Buffer.Content); + } + finally + { + AnsiConsole.Console = saved; + } + } + + [Fact] + public void ReverseSearch_FindInitialForwardMatch_NoMatch_ReturnsNone() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query"); + + Assert.False(result.HasMatch); + } + + [Fact] + public void ReverseSearch_FindInitialForwardMatch_ReturnsOldestMatch() + { + var history = new[] { "query a", "ls", "query b" }; + + var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query"); + + Assert.True(result.HasMatch); + Assert.Equal("query a", result.Match); + } + + [Fact] + public void ReverseSearch_FindPreviousMatch_NoMatch_ReturnsNone() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0); + + Assert.False(result.HasMatch); + } + + [Fact] + public void ReverseSearch_FindInitialMatch_NoMatch_ReturnsNone() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindInitialMatch(history, "query"); + + Assert.False(result.HasMatch); + } + private static LineEditorContext CreateContext(string content, int position) { var buffer = new LineBuffer(content); buffer.Move(position); return new LineEditorContext(buffer, null!); } -} +} \ No newline at end of file diff --git a/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs new file mode 100644 index 0000000..a24b245 --- /dev/null +++ b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs @@ -0,0 +1,219 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Mcp; + +using Microsoft.Extensions.Logging.Abstractions; + +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +using Spectre.Console; + +// Exercises ToolOperations.CallToolHandler / ListToolsHandler against the shared +// ShellInterpreter.Instance singleton. Placed in the theme-state collection so the +// success path (which writes the highlighted command line through AnsiConsole) does +// not race with other tests that swap the global console or theme. +[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] +public class ToolOperationsCallToolTests +{ + private static ToolOperations CreateToolOperations() + { + return new ToolOperations(NullLogger.Instance); + } + + private static RequestContext CallContext(string? name, Dictionary? arguments = null) + { + var context = (RequestContext)RuntimeHelpers.GetUninitializedObject( + typeof(RequestContext)); + if (name != null) + { + context.Params = new CallToolRequestParams { Name = name, Arguments = arguments }; + } + + return context; + } + + private static RequestContext ListContext() + { + return (RequestContext)RuntimeHelpers.GetUninitializedObject( + typeof(RequestContext)); + } + + private static JsonElement Json(string raw) + { + using var document = JsonDocument.Parse(raw); + return document.RootElement.Clone(); + } + + private static (bool IsError, JsonElement Root, JsonDocument Document) ReadResult(CallToolResult result) + { + var text = Assert.IsType(Assert.Single(result.Content)).Text; + var document = JsonDocument.Parse(text); + return (result.IsError == true, document.RootElement, document); + } + + [Fact] + public async Task CallTool_NullParams_ReturnsError() + { + var tool = CreateToolOperations(); + + var result = await tool.CallToolHandler(CallContext(null), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.True(isError); + Assert.Contains("null parameters", root.GetProperty("error").GetString()); + } + } + + [Fact] + public async Task CallTool_UnknownCommand_ReturnsError() + { + var tool = CreateToolOperations(); + + var result = await tool.CallToolHandler(CallContext("definitely-not-a-command"), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.True(isError); + Assert.Contains("Could not find command", root.GetProperty("error").GetString()); + } + } + + [Fact] + public async Task CallTool_RestrictedCommand_ReturnsError() + { + var tool = CreateToolOperations(); + Assert.True(ShellInterpreter.Instance.App.Commands["delete"].McpRestricted); + + var result = await tool.CallToolHandler(CallContext("delete"), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.True(isError); + Assert.Contains("restricted for MCP", root.GetProperty("error").GetString()); + } + } + + [Fact] + public async Task CallTool_UnknownArgument_ReturnsErrorListingKnownArguments() + { + var tool = CreateToolOperations(); + var arguments = new Dictionary + { + ["bogus"] = Json("\"value\""), + }; + + var result = await tool.CallToolHandler(CallContext("echo", arguments), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.True(isError); + var error = root.GetProperty("error").GetString(); + Assert.Contains("Unknown argument 'bogus'", error); + Assert.Contains("Known arguments:", error); + } + } + + [Fact] + public async Task CallTool_MissingRequiredParameter_ReturnsError() + { + var tool = CreateToolOperations(); + + // 'query' requires the 'query' parameter; supplying none triggers the missing-required path. + var result = await tool.CallToolHandler(CallContext("query"), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.True(isError); + Assert.Contains("Missing required parameter", root.GetProperty("error").GetString()); + } + } + + [Fact] + public async Task CallTool_InvalidValueType_ReturnsSanitizedError() + { + var tool = CreateToolOperations(); + var arguments = new Dictionary + { + // 'max' is an integer option; a non-numeric string cannot convert. + ["max"] = Json("\"not-a-number\""), + }; + + var result = await tool.CallToolHandler(CallContext("query", arguments), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.True(isError); + var error = root.GetProperty("error").GetString(); + Assert.Contains("Invalid value for option '--max'", error); + // The offending raw value must never be echoed back (secret-redaction contract). + Assert.DoesNotContain("not-a-number", error); + } + } + + [Fact] + public async Task CallTool_EchoCommand_ReturnsSuccessResult() + { + var tool = CreateToolOperations(); + var arguments = new Dictionary + { + ["messages"] = Json("[\"hello\", \"world\"]"), + }; + + var saved = AnsiConsole.Console; + try + { + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter()), + }); + + var result = await tool.CallToolHandler(CallContext("echo", arguments), CancellationToken.None); + + var (isError, root, document) = ReadResult(result); + using (document) + { + Assert.False(isError); + Assert.Equal("hello world", root.GetProperty("result").GetString()); + Assert.True(root.TryGetProperty("currentLocation", out _)); + } + } + finally + { + AnsiConsole.Console = saved; + } + } + + [Fact] + public async Task ListTools_ReturnsRegisteredTools() + { + var tool = CreateToolOperations(); + + var result = await tool.ListToolsHandler(ListContext(), CancellationToken.None); + + Assert.NotEmpty(result.Tools); + Assert.Contains(result.Tools, t => t.Name == "query"); + Assert.Contains(result.Tools, t => t.Name == "echo"); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs index d4ce491..9b90aa9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs @@ -4,6 +4,7 @@ namespace Azure.Data.Cosmos.Shell.Parser; +using System.Globalization; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Parser; @@ -53,6 +54,14 @@ public static ShellObject Parse(Lexer lexer) case TokenType.String: // Quoted strings are always text return new ShellText(token.Value); + case TokenType.Number: + return int.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int numberValue) + ? new ShellNumber(numberValue) + : new ShellIdentifier(token.Value); + case TokenType.Decimal: + return double.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double decimalValue) + ? new ShellDecimal(decimalValue) + : new ShellIdentifier(token.Value); default: // For any other token type, treat as text return new ShellIdentifier(token.Value); diff --git a/tools/coverage.ps1 b/tools/coverage.ps1 new file mode 100644 index 0000000..af3a0fd --- /dev/null +++ b/tools/coverage.ps1 @@ -0,0 +1,94 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Runs the unit tests with code coverage and generates a human readable report. + +.DESCRIPTION + Collects coverage with coverlet (via "dotnet test --collect") and renders the + result with ReportGenerator. The report contains an overall summary as well as + a per-namespace and per-class breakdown. + + An interactive HTML report is written to the output directory and a text summary + is printed to the console. The HTML report has a "Group by" selector that allows + grouping the coverage numbers by namespace. + +.PARAMETER Configuration + Build configuration to test. Defaults to "Debug". + +.PARAMETER Output + Directory for the generated coverage report. Defaults to "TestResults/coverage". + +.PARAMETER NoOpen + Do not open the generated HTML report in the default browser. + +.EXAMPLE + ./tools/coverage.ps1 + +.EXAMPLE + ./tools/coverage.ps1 -Configuration Release -NoOpen +#> +[CmdletBinding()] +param( + [string]$Configuration = 'Debug', + [string]$Output = 'TestResults/coverage', + [switch]$NoOpen +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + $testProject = Join-Path $repoRoot 'CosmosDBShell.Tests/CosmosDBShell.Tests.csproj' + $resultsDir = Join-Path $repoRoot 'TestResults/coverage-raw' + $reportDir = Join-Path $repoRoot $Output + + if (Test-Path $resultsDir) { + Remove-Item -Recurse -Force $resultsDir + } + + Write-Host 'Restoring local dotnet tools...' -ForegroundColor Cyan + dotnet tool restore + if ($LASTEXITCODE -ne 0) { throw "dotnet tool restore failed with exit code $LASTEXITCODE." } + + Write-Host 'Running tests with coverage...' -ForegroundColor Cyan + dotnet test $testProject ` + --configuration $Configuration ` + --collect:'XPlat Code Coverage' ` + --results-directory $resultsDir + if ($LASTEXITCODE -ne 0) { throw "dotnet test failed with exit code $LASTEXITCODE." } + + $coverageFiles = Get-ChildItem -Path $resultsDir -Recurse -Filter 'coverage.cobertura.xml' + if (-not $coverageFiles) { + throw "No coverage files found under '$resultsDir'." + } + + Write-Host 'Generating coverage report...' -ForegroundColor Cyan + $reports = ($coverageFiles.FullName -join ';') + dotnet tool run reportgenerator ` + "-reports:$reports" ` + "-targetdir:$reportDir" ` + '-reporttypes:Html;TextSummary' ` + '-title:CosmosDBShell Code Coverage' + if ($LASTEXITCODE -ne 0) { throw "reportgenerator failed with exit code $LASTEXITCODE." } + + $summaryFile = Join-Path $reportDir 'Summary.txt' + if (Test-Path $summaryFile) { + Write-Host '' + Get-Content $summaryFile | Write-Host + } + + $htmlReport = Join-Path $reportDir 'index.html' + Write-Host '' + Write-Host "HTML report: $htmlReport" -ForegroundColor Green + Write-Host 'Tip: use the "Group by" selector in the report to view coverage per namespace.' -ForegroundColor DarkGray + + if (-not $NoOpen -and (Test-Path $htmlReport)) { + if ($IsWindows -or $null -eq $IsWindows) { + Start-Process $htmlReport + } + } +} +finally { + Pop-Location +}