From 8a01a4d42bfed595ba2a3f87c9fbbb945ecb49c5 Mon Sep 17 00:00:00 2001 From: Jon Manning Date: Mon, 26 Jun 2023 11:38:27 +1000 Subject: [PATCH] Add tests for dealing with multiple projects in workspace --- .gitignore | 5 +- .../CommandTests.cs | 14 +-- .../LanguageServer.Tests.csproj | 2 +- .../LanguageServerTests.cs | 41 ++++++--- .../LanguageServerTestsBase.cs | 90 +++++++++---------- .../FilesWithNoProject/Test.yarn | 4 + .../Project1/Project1.yarnproject | 5 ++ .../{ => TestWorkspace/Project1}/Test.yarn | 3 +- .../Project2/Functions.ysls.json | 25 ++++++ .../Project2/Project2.yarnproject | 6 ++ .../TestData/TestWorkspace/Project2/Test.yarn | 9 ++ .../TestUtility.cs | 43 +++++++++ .../WorkspaceTests.cs | 90 +++++++++++++++++++ 13 files changed, 265 insertions(+), 72 deletions(-) create mode 100644 YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/FilesWithNoProject/Test.yarn create mode 100644 YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Project1.yarnproject rename YarnSpinner.LanguageServer.Tests/TestData/{ => TestWorkspace/Project1}/Test.yarn (67%) create mode 100644 YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Functions.ysls.json create mode 100644 YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Project2.yarnproject create mode 100644 YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Test.yarn create mode 100644 YarnSpinner.LanguageServer.Tests/TestUtility.cs create mode 100644 YarnSpinner.LanguageServer.Tests/WorkspaceTests.cs diff --git a/.gitignore b/.gitignore index 7113b7f85..1de890300 100644 --- a/.gitignore +++ b/.gitignore @@ -151,4 +151,7 @@ export.log /_api/ # Test coverage data -YarnSpinner.Tests/coverage/ \ No newline at end of file +YarnSpinner.Tests/coverage/ + +# Folders marked as containing non-public data +*DoNotCommit/ diff --git a/YarnSpinner.LanguageServer.Tests/CommandTests.cs b/YarnSpinner.LanguageServer.Tests/CommandTests.cs index 6b631f15b..f434e5511 100644 --- a/YarnSpinner.LanguageServer.Tests/CommandTests.cs +++ b/YarnSpinner.LanguageServer.Tests/CommandTests.cs @@ -1,13 +1,13 @@ using System.IO; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Models; using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; using Xunit; using Xunit.Abstractions; -using YarnLanguageServer; -using System.Linq; namespace YarnLanguageServer.Tests; @@ -24,7 +24,7 @@ public async Task Server_CanListNodesInFile() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); var result = await client.ExecuteCommand(new ExecuteCommandParams> { @@ -70,7 +70,7 @@ public async Task Server_OnAddNodeCommand_ReturnsTextEdit() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); NodesChangedParams? nodeInfo; @@ -111,7 +111,7 @@ public async Task Server_OnRemoveNodeCommand_ReturnsTextEdit() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); NodesChangedParams? nodeInfo; @@ -147,7 +147,7 @@ public async Task Server_OnUpdateHeaderCommand_ReturnsTextEditCreatingHeader() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); NodesChangedParams? nodeInfo; @@ -195,7 +195,7 @@ public async Task Server_OnUpdateHeaderCommand_ReturnsTextEditModifyingHeader() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); NodesChangedParams? nodeInfo; diff --git a/YarnSpinner.LanguageServer.Tests/LanguageServer.Tests.csproj b/YarnSpinner.LanguageServer.Tests/LanguageServer.Tests.csproj index 148d434ac..d39896e56 100644 --- a/YarnSpinner.LanguageServer.Tests/LanguageServer.Tests.csproj +++ b/YarnSpinner.LanguageServer.Tests/LanguageServer.Tests.csproj @@ -26,7 +26,7 @@ - + diff --git a/YarnSpinner.LanguageServer.Tests/LanguageServerTests.cs b/YarnSpinner.LanguageServer.Tests/LanguageServerTests.cs index 82a9f55ef..13442adc7 100644 --- a/YarnSpinner.LanguageServer.Tests/LanguageServerTests.cs +++ b/YarnSpinner.LanguageServer.Tests/LanguageServerTests.cs @@ -35,7 +35,7 @@ public async Task Server_OnEnteringACommand_ShouldReceiveCompletions() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); // Start typing a command ChangeTextInDocument(client, filePath, new Position(8, 0), "<<"); @@ -98,21 +98,28 @@ public async Task Server_OnOpeningDocument_SendsNodesChangedNotification() [Fact] public async Task Server_OnChangingDocument_SendsNodesChangedNotification() { + var nodesChangedOnInitialization = GetNodesChangedNotificationAsync((nodesResult) => + nodesResult.Uri.AbsolutePath.Contains(Path.Combine("Project1", "Test.yarn")) + ); + var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); NodesChangedParams? nodeInfo; - - nodeInfo = await GetNodesChangedNotificationAsync(); + + // Await a notification that nodes changed in this file + nodeInfo = await nodesChangedOnInitialization; nodeInfo.Uri.ToString().Should().Be("file://" + filePath, "because this is the URI of the file we opened"); nodeInfo.Nodes.Should().HaveCount(2, "because there are two nodes in the file before we make changes"); - ChangeTextInDocument(client, filePath, new Position(19, 0), "title: Node3\n---\n===\n"); + ChangeTextInDocument(client, filePath, new Position(20, 0), "title: Node3\n---\n===\n"); - nodeInfo = await GetNodesChangedNotificationAsync(); + nodeInfo = await GetNodesChangedNotificationAsync((nodesResult) => + nodesResult.Uri.AbsolutePath.Contains(Path.Combine("Project1", "Test.yarn")) + ); nodeInfo.Nodes.Should().HaveCount(3, "because we added a new node"); nodeInfo.Nodes.Should().Contain(n => n.Title == "Node3", "because the new node we added has this title"); @@ -124,26 +131,32 @@ public async Task Server_OnInvalidChanges_ProducesSyntaxErrors() var (client, server) = await Initialize(ConfigureClient, ConfigureServer); { - var errors = (await GetDiagnosticsAsync()).Where(d => d.Severity == DiagnosticSeverity.Error); + var errors = (await GetDiagnosticsAsync()).Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error); - errors.Should().BeNullOrEmpty("because the original document contains no syntax errors"); + errors.Should().BeNullOrEmpty("because the original project contains no syntax errors"); } // Introduce an error - var filePath = Path.Combine(PathToTestData, "Test.yarn"); - ChangeTextInDocument(client, filePath, new Position(8, 0), "< d.Severity == DiagnosticSeverity.Error); + var diagnosticsResult = await GetDiagnosticsAsync(diags => diags.Uri.ToString().Contains("Test.yarn")); + + var enumerable = diagnosticsResult.Diagnostics; + + var errors = enumerable.Where(d => d.Severity == DiagnosticSeverity.Error); errors.Should().NotBeNullOrEmpty("because we have introduced a syntax error"); } // Remove the error - ChangeTextInDocument(client, filePath, new Position(8, 0), new Position(8, 5), ""); + ChangeTextInDocument(client, filePath, new Position(9, 0), new Position(9, 5), ""); { - var errors = (await GetDiagnosticsAsync()).Where(d => d.Severity == DiagnosticSeverity.Error); + PublishDiagnosticsParams diagnosticsResult = await GetDiagnosticsAsync(diags => diags.Uri.ToString().Contains("Test.yarn")); + + var errors = diagnosticsResult.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error); errors.Should().BeNullOrEmpty("because the syntax error was removed"); } @@ -154,7 +167,7 @@ public async Task Server_OnJumpCommand_ShouldReceiveNodeNameCompletions() { // Set up the server var (client, server) = await Initialize(ConfigureClient, ConfigureServer); - var filePath = Path.Combine(PathToTestData, "Test.yarn"); + var filePath = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn"); CompletionList? completions; // The line in Test.yarn we're inserting the new jump command on. diff --git a/YarnSpinner.LanguageServer.Tests/LanguageServerTestsBase.cs b/YarnSpinner.LanguageServer.Tests/LanguageServerTestsBase.cs index fcdd84dae..c8ef243eb 100644 --- a/YarnSpinner.LanguageServer.Tests/LanguageServerTestsBase.cs +++ b/YarnSpinner.LanguageServer.Tests/LanguageServerTestsBase.cs @@ -16,13 +16,35 @@ using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; using OmniSharp.Extensions.LanguageServer.Protocol.Client; +class NotificationListeners : HashSet<(TaskCompletionSource Task, System.Func Test)> { + public TaskCompletionSource AddListener(Func? test) { + var completionSource = new TaskCompletionSource(); + + if (test == null) { + // If no test is provided, use a test that always returns true. + test = (item) => true; + } + + this.Add((Task: completionSource, Test: test)); + return completionSource; + } + + public void ApplyResult(T result) { + var completed = this.Where(item => item.Test(result)).ToList(); + foreach (var item in completed) { + item.Task.TrySetResult(result); + } + this.ExceptWith(completed); + } +} + #pragma warning disable CS0162 #pragma warning disable VSTHRD200 // async method names should end with "Async" namespace YarnLanguageServer.Tests { - public class LanguageServerTestsBase : LanguageProtocolTestBase + public abstract class LanguageServerTestsBase : LanguageProtocolTestBase { public LanguageServerTestsBase(ITestOutputHelper outputHelper) : base( new JsonRpcTestOptions() @@ -31,43 +53,11 @@ public LanguageServerTestsBase(ITestOutputHelper outputHelper) : base( { } - TaskCompletionSource> ReceivedDiagnosticsNotification = new(); - - TaskCompletionSource NodesChangedNotification = new(); - - protected static string PathToTestData - { - get - { - var context = AppContext.BaseDirectory; - - var directoryContainingProject = GetParentDirectoryContainingFile(new DirectoryInfo(context), "*.csproj"); + NotificationListeners ReceivedDiagnosticsNotifications = new(); - if (directoryContainingProject != null) - { - return Path.Combine(directoryContainingProject.FullName, "TestData"); - } - else - { - throw new InvalidOperationException("Failed to find path containing .csproj!"); - } + NotificationListeners NodesChangedNotification = new(); - static DirectoryInfo? GetParentDirectoryContainingFile(DirectoryInfo directory, string filePattern) - { - var current = directory; - do - { - if (current.EnumerateFiles(filePattern).Any()) - { - return current; - } - current = current.Parent; - } while (current != null); - - return null; - } - } - } + protected virtual string RootPath => TestUtility.PathToTestWorkspace; protected static void ChangeTextInDocument(ILanguageClient client, string fileURI, Position start, string text) { @@ -128,18 +118,22 @@ protected void ConfigureClient(LanguageClientOptions options) options.OnPublishDiagnostics((diagnosticsParams) => { - var diagnostics = diagnosticsParams.Diagnostics.ToList(); - ReceivedDiagnosticsNotification.TrySetResult(diagnostics); + ReceivedDiagnosticsNotifications.ApplyResult(diagnosticsParams); }); void OnNodesChangedNotification(NodesChangedParams nodesChangedParams) { - NodesChangedNotification.TrySetResult(nodesChangedParams); + NodesChangedNotification.ApplyResult(nodesChangedParams); } options.OnNotification(Commands.DidChangeNodesNotification, (Action)OnNodesChangedNotification); - options.WithRootPath(PathToTestData); + options.ConfigureConfiguration(config => + { + config.Properties.Add("yarnspinner.CSharpLookup", true); + }); + + options.WithRootPath(this.RootPath); } protected static void ConfigureServer(LanguageServerOptions options) @@ -147,7 +141,7 @@ protected static void ConfigureServer(LanguageServerOptions options) YarnLanguageServer.ConfigureOptions(options); } - protected async Task GetTaskResultOrTimeoutAsync(TaskCompletionSource task, Action onCompletion, double timeout = 2f) { + protected async Task GetTaskResultOrTimeoutAsync(TaskCompletionSource task, System.Action? onCompletion, double timeout = 2f) { try { // Timeout. @@ -165,7 +159,7 @@ protected async Task GetTaskResultOrTimeoutAsync(TaskCompletionSource t finally { // Get ready for the next call - onCompletion(); + onCompletion?.Invoke(); } } @@ -178,19 +172,19 @@ protected async Task GetTaskResultOrTimeoutAsync(TaskCompletionSource t /// The amount of time to wait for /// diagnostics. /// A collection of objects. - protected async Task> GetDiagnosticsAsync(double timeout = 2f) + protected async Task GetDiagnosticsAsync(Func? test = null, double timeout = 2f) { return await GetTaskResultOrTimeoutAsync( - ReceivedDiagnosticsNotification, - () => ReceivedDiagnosticsNotification = new(), + ReceivedDiagnosticsNotifications.AddListener(test) , + null, timeout ); } - protected async Task GetNodesChangedNotificationAsync(double timeout = 2f) { + protected async Task GetNodesChangedNotificationAsync(Func? test = null, double timeout = 2f) { return await GetTaskResultOrTimeoutAsync( - NodesChangedNotification, - () => NodesChangedNotification = new(), + NodesChangedNotification.AddListener(test), + null, timeout ); } diff --git a/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/FilesWithNoProject/Test.yarn b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/FilesWithNoProject/Test.yarn new file mode 100644 index 000000000..741e6973e --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/FilesWithNoProject/Test.yarn @@ -0,0 +1,4 @@ +title: NotIncludedInProject +--- +// This file is not in a directory that has a .yarnproject file. +=== diff --git a/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Project1.yarnproject b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Project1.yarnproject new file mode 100644 index 000000000..60a3996e8 --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Project1.yarnproject @@ -0,0 +1,5 @@ +{ + "projectFileVersion": 2, + "sourceFiles": ["**/*.yarn"], + "baseLanguage": "en", +} \ No newline at end of file diff --git a/YarnSpinner.LanguageServer.Tests/TestData/Test.yarn b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Test.yarn similarity index 67% rename from YarnSpinner.LanguageServer.Tests/TestData/Test.yarn rename to YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Test.yarn index 94317aeda..109c1a544 100644 --- a/YarnSpinner.LanguageServer.Tests/TestData/Test.yarn +++ b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project1/Test.yarn @@ -5,7 +5,8 @@ This is a line. -> Option 1 -> Option 2 -<> +// This command is unknown to the LSP and should produce a warning +<> <> diff --git a/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Functions.ysls.json b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Functions.ysls.json new file mode 100644 index 000000000..d9bc2fc1c --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Functions.ysls.json @@ -0,0 +1,25 @@ +{ + "Commands": [ + { + "YarnName": "custom_command", + "Parameters": [ + { + "Name": "target", + "Type": "string" + } + ] + } + ], + "Functions": [ + { + "YarnName": "custom_function", + "ReturnType": "bool", + "Parameters": [ + { + "Name": "value", + "Type": "number" + } + ] + } + ] +} \ No newline at end of file diff --git a/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Project2.yarnproject b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Project2.yarnproject new file mode 100644 index 000000000..4f5f1414a --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Project2.yarnproject @@ -0,0 +1,6 @@ +{ + "projectFileVersion": 2, + "sourceFiles": ["**/*.yarn"], + "baseLanguage": "en", + "definitions": "Functions.ysls.json" +} \ No newline at end of file diff --git a/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Test.yarn b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Test.yarn new file mode 100644 index 000000000..8f7414e66 --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/TestData/TestWorkspace/Project2/Test.yarn @@ -0,0 +1,9 @@ +title: Start +--- +<> +<> + +<> +<> +<> +=== \ No newline at end of file diff --git a/YarnSpinner.LanguageServer.Tests/TestUtility.cs b/YarnSpinner.LanguageServer.Tests/TestUtility.cs new file mode 100644 index 000000000..ac1f58dd6 --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/TestUtility.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; + +public static class TestUtility +{ + public static string PathToTestWorkspace => Path.Combine(PathToTestData, "TestWorkspace"); + + public static string PathToTestData + { + get + { + var context = AppContext.BaseDirectory; + + var directoryContainingProject = GetParentDirectoryContainingFile(new DirectoryInfo(context), "*.csproj"); + + if (directoryContainingProject != null) + { + return Path.Combine(directoryContainingProject.FullName, "TestData"); + } + else + { + throw new InvalidOperationException("Failed to find path containing .csproj!"); + } + + static DirectoryInfo? GetParentDirectoryContainingFile(DirectoryInfo directory, string filePattern) + { + var current = directory; + do + { + if (current.EnumerateFiles(filePattern).Any()) + { + return current; + } + current = current.Parent; + } while (current != null); + + return null; + } + } + } +} diff --git a/YarnSpinner.LanguageServer.Tests/WorkspaceTests.cs b/YarnSpinner.LanguageServer.Tests/WorkspaceTests.cs new file mode 100644 index 000000000..5978e52e4 --- /dev/null +++ b/YarnSpinner.LanguageServer.Tests/WorkspaceTests.cs @@ -0,0 +1,90 @@ +using Xunit; +using System.Linq; +using FluentAssertions; +using System.IO; +using YarnLanguageServer.Diagnostics; +using OmniSharp.Extensions.LanguageServer.Protocol; + +namespace YarnLanguageServer.Tests +{ + public class WorkspaceTests + { + private static string Project1Path = Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Project1.yarnproject"); + private static string Project2Path = Path.Combine(TestUtility.PathToTestWorkspace, "Project2", "Project1.yarnproject"); + private static string NoProjectPath = Path.Combine(TestUtility.PathToTestWorkspace, "FilesWithNoProject"); + + [Fact] + public void Projects_CanOpen() + { + // Given + var project = new Project(Project1Path); + + // When + project.ReloadProjectFromDisk(false); + + // Then + project.Files.Should().HaveCount(1); + project.Nodes.Should().HaveCount(2); + project.Files.Should().AllSatisfy(file => file.Project.Should().Be(project)); + + var testFilePath = DocumentUri.FromFileSystemPath(Path.Combine(TestUtility.PathToTestWorkspace, "Project1", "Test.yarn")); + + project.MatchesUri(Project1Path).Should().BeTrue(); + project.MatchesUri(testFilePath).Should().BeTrue(); + } + + [Fact] + public void Workspaces_CanOpen() { + var workspace = new Workspace(); + workspace.Root = TestUtility.PathToTestWorkspace; + workspace.Initialize(null); + + var diagnostics = workspace.GetDiagnostics(); + + workspace.Projects.SelectMany(p => p.Nodes).Should().NotBeEmpty(); + + workspace.Projects.Should().HaveCount(2); + + // The node NotIncludedInProject is inside a file that is not + // included in a .yarnproject; because we have opened a workspace + // that includes .yarnprojects, the file will not be included + workspace.Projects.Should().AllSatisfy(p => p.Nodes.Should().NotContain(n => n.Title == "NotIncludedInProject")); + + var firstProject = workspace.Projects.Should().ContainSingle(p => p.Uri.Path.Contains("Project1.yarnproject")).Subject; + var fileInFirstProject = firstProject.Files.Should().ContainSingle().Subject; + + // Validate that diagnostics are being generated by looking for a warning that + // '<>' is being warned about. + var fileDiagnostics = diagnostics.Should().ContainKey(fileInFirstProject.Uri).WhoseValue; + fileDiagnostics.Should().NotBeEmpty(); + fileDiagnostics.Should().Contain(d => d.Code!.Value.String == nameof(YarnDiagnosticCode.YRNMsngCmdDef) && d.Message.Contains("unknown_command")); + } + + [Fact] + public void Workspaces_WithNoProjects_HaveImplicitProject() + { + // Given + var workspace = new Workspace(); + workspace.Root = NoProjectPath; + workspace.Initialize(null); + + // Then + var project = workspace.Projects.Should().ContainSingle().Subject; + var file = project.Files.Should().ContainSingle().Subject; + file.NodeInfos.Should().Contain(n => n.Title == "NotIncludedInProject"); + } + + [Fact] + public void Workspaces_WithDefinitionsFile_UseDefinitions() + { + // Given + var workspace = new Workspace(); + workspace.Root = Project2Path; + workspace.Initialize(null); + + // When + + // Then + } + } +}