Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CSharpRepl.Tests/ConfigurationFileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

using System.IO;
using Xunit;

namespace CSharpRepl.Tests;

public class ConfigurationFileTests
{
/// <summary>
/// When no configuration file exists yet, <see cref="CommandLine.Parse"/> writes a default
/// commented-out .rsp file listing the available options. This drives that whole code path
/// (ConfigurationFile.CreateDefaultConfigurationFile) through the real command line definition.
/// </summary>
[Fact]
public void Parse_ConfigFileDoesNotExist_WritesDefaultConfigurationFile()
{
var configFilePath = Path.Combine(Path.GetTempPath(), $"csharprepl-test-config-{Path.GetRandomFileName()}.rsp");
Assert.False(File.Exists(configFilePath));

try
{
_ = CommandLine.Parse([], configFilePath);

Assert.True(File.Exists(configFilePath));
var contents = File.ReadAllText(configFilePath);

// header
Assert.Contains("# Add csharprepl command line options to this file to configure csharprepl.", contents);
Assert.Contains("# You may uncomment an option below by removing the leading '#' character.", contents);

// a representative option, with its description and (System.CommandLine v3) "--"-prefixed name
Assert.Contains("# Reference a shared framework.", contents);
Assert.Contains("--framework <", contents);

// ignored options (help/version/configure) should not be written into the file
Assert.DoesNotContain("--configure <", contents);
}
finally
{
if (File.Exists(configFilePath)) File.Delete(configFilePath);
}
}

/// <summary>
/// If the configuration file already exists, Parse must not overwrite it.
/// </summary>
[Fact]
public void Parse_ConfigFileExists_DoesNotOverwrite()
{
var configFilePath = Path.Combine(Path.GetTempPath(), $"csharprepl-test-config-{Path.GetRandomFileName()}.rsp");
File.WriteAllText(configFilePath, "# user customized");

try
{
_ = CommandLine.Parse([], configFilePath);
Assert.Equal("# user customized", File.ReadAllText(configFilePath));
}
finally
{
if (File.Exists(configFilePath)) File.Delete(configFilePath);
}
}
}
56 changes: 56 additions & 0 deletions CSharpRepl.Tests/ObjectFormatting/PrettyPrinterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,64 @@ obj is Array or List<int> ?
[typeof(int?), Level.FirstDetailed, "System.Nullable<System.Int32>"],

[Encoding.UTF8, Level.FirstDetailed, "System.Text.UTF8Encoding.UTF8EncodingSealed"],

// primitive / scalar values exercise PrimitiveFormatter's per-type literal formatting.
// Numbers are formatted identically regardless of detail level.
[(byte)200, Level.FirstSimple, "200"],
[(byte)200, Level.FirstDetailed, "200"],
[(sbyte)-5, Level.FirstSimple, "-5"],
[(sbyte)-5, Level.FirstDetailed, "-5"],
[(short)-300, Level.FirstSimple, "-300"],
[(short)-300, Level.FirstDetailed, "-300"],
[(ushort)300, Level.FirstSimple, "300"],
[(ushort)300, Level.FirstDetailed, "300"],
[42, Level.FirstSimple, "42"],
[42, Level.FirstDetailed, "42"],
[42u, Level.FirstSimple, "42"],
[42u, Level.FirstDetailed, "42"],
[42L, Level.FirstSimple, "42"],
[42L, Level.FirstDetailed, "42"],
[42UL, Level.FirstSimple, "42"],
[42UL, Level.FirstDetailed, "42"],
[3.14, Level.FirstSimple, "3.14"],
[3.14, Level.FirstDetailed, "3.14"],
[1.5f, Level.FirstSimple, "1.5"],
[1.5f, Level.FirstDetailed, "1.5"],
[2.5m, Level.FirstSimple, "2.5"],
[2.5m, Level.FirstDetailed, "2.5"],
[true, Level.FirstSimple, "true"],
[false, Level.FirstSimple, "false"],
['a', Level.FirstSimple, "'a'"],
['a', Level.FirstDetailed, "'a'"],
['\n', Level.FirstSimple, @"'\n'"],
[DayOfWeek.Monday, Level.FirstSimple, "Monday"],
[DayOfWeek.Monday, Level.FirstDetailed, "Monday"],

// multidimensional and jagged arrays go through IEnumerableFormatter.FormatToText.
[new int[,] { { 1, 2 }, { 3, 4 } }, Level.FirstSimple, "int[4] { 1, 2, 3, 4 }"],
[new int[,] { { 1, 2 }, { 3, 4 } }, Level.FirstDetailed, "int[4] { 1, 2, 3, 4 }"],
[new int[][] { [1, 2], [3] }, Level.FirstSimple, "int[][2] { int[2] { 1, 2 }, int[1] { 3 } }"],
[new int[][] { [1, 2], [3] }, Level.FirstDetailed, "int[][2] { int[2] { 1, 2 }, int[1] { 3 } }"],
];

/// <summary>
/// At first-level detail an <see cref="IEnumerable"/> is rendered as a Name/Value/Type table
/// (IEnumerableFormatter.FormatToRenderable) rather than the inline "{ ... }" text form.
/// </summary>
[Theory]
[InlineData(Level.FirstSimple, @"{ ""a"", 1 }")]
[InlineData(Level.FirstDetailed, @"{ Key: ""a"", Value: 1 }")]
public void FormatObject_Dictionary_RendersAsTableWithEntries(Level level, string expectedValueCell)
{
var dictionary = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };

var output = ToString(prettyPrinter.FormatObject(dictionary, level).Renderable);

Assert.Contains("Dictionary<string, int>(2)", output); // header with element count
Assert.Contains("KeyValuePair<string, int>", output); // Type column
Assert.Contains(expectedValueCell, output); // Value column entry
}

[Theory]
[MemberData(nameof(ObjectMembersFormattingInputs))]
public void TestObjectMembersFormatting(object obj, Level level, string[] expectedResults, bool includeNonPublic)
Expand Down
52 changes: 52 additions & 0 deletions CSharpRepl.Tests/OpenAICompleteServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

using System.Collections.Generic;
using System.Threading.Tasks;
using CSharpRepl.Services;
using CSharpRepl.Services.Completion;
using Xunit;

namespace CSharpRepl.Tests;

public class OpenAICompleteServiceTests
{
[Fact]
public async Task CompleteAsync_NoConfiguration_YieldsNothing()
{
var service = new OpenAICompleteService(configuration: null);

var results = new List<string>();
await foreach (var chunk in service.CompleteAsync([], "Console.Wri", caret: 11, cancellationToken: default))
{
results.Add(chunk);
}

Assert.Empty(results);
}

[Fact]
public async Task CompleteAsync_EmptyApiKey_YieldsNothing()
{
var service = new OpenAICompleteService(new OpenAIConfiguration(apiKey: "", prompt: "p", model: "gpt-4o", historyCount: 5));

var results = new List<string>();
await foreach (var chunk in service.CompleteAsync(["previous submission"], "1 + ", caret: 4, cancellationToken: default))
{
results.Add(chunk);
}

Assert.Empty(results);
}

[Fact]
public void Constructor_WithApiKey_DoesNotThrow()
{
// Constructing the underlying ChatClient does not make a network call; only completing would.
var service = new OpenAICompleteService(
new OpenAIConfiguration(apiKey: "sk-fake-key-for-construction-only", prompt: "p", model: "gpt-4o", historyCount: 5));

Assert.NotNull(service);
}
}
36 changes: 36 additions & 0 deletions CSharpRepl.Tests/PipedInputEvaluatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ public PipedInputEvaluatorTests(RoslynServicesFixture fixture)
this.pipedInputEvaluator = new PipedInputEvaluator(console, roslyn);
}

[Fact]
public async Task EvaluateCollectedPipeInputAsync_InputThrows_ReturnsErrorCodeAndWritesMessage()
{
// Use a fresh console so toggling IsErrorRedirected / the ReadLine stub doesn't leak into
// the other tests sharing the RoslynServices fixture collection.
var (errorConsole, _, stderr) = FakeConsole.CreateStubbedOutputAndError();
errorConsole.PrettyPromptConsole.IsErrorRedirected = true; // route WriteErrorLine to the captured error buffer
errorConsole.ReadLine().Returns(
@"throw new System.Exception(""boom"");",
(string)null // end of piped input (cast so NSubstitute treats it as a value, not a null params array)
);
var evaluator = new PipedInputEvaluator(errorConsole, roslyn);

var result = await evaluator.EvaluateCollectedPipeInputAsync();

Assert.NotEqual(ExitCodes.Success, result);
Assert.Contains("boom", stderr.ToString());
}

[Fact]
public async Task EvaluateStreamingPipeInputAsync_InputThrows_ReturnsErrorCodeAndWritesMessage()
{
var (errorConsole, _, stderr) = FakeConsole.CreateStubbedOutputAndError();
errorConsole.PrettyPromptConsole.IsErrorRedirected = true;
errorConsole.ReadLine().Returns(
@"throw new System.Exception(""kaboom"");",
(string)null // end of piped input (cast so NSubstitute treats it as a value, not a null params array)
);
var evaluator = new PipedInputEvaluator(errorConsole, roslyn);

var result = await evaluator.EvaluateStreamingPipeInputAsync();

Assert.NotEqual(ExitCodes.Success, result);
Assert.Contains("kaboom", stderr.ToString());
}

[Fact]
public async Task EvaluateCollectedPipeInputAsync_FullyCollectsInput_ThenEvaluatesInput()
{
Expand Down
60 changes: 60 additions & 0 deletions CSharpRepl.Tests/ProgramPipedInputTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

using System;
using System.IO;
using System.Threading.Tasks;
using CSharpRepl.Services.Roslyn;
using Xunit;

namespace CSharpRepl.Tests;

/// <summary>
/// Drives <see cref="Program.Main"/> end-to-end in its non-interactive "piped input" mode (the branch
/// taken when stdin is redirected). Lives in the RoslynServices collection so its Roslyn initialization
/// is serialized with the other heavy tests rather than contending with them in parallel.
/// </summary>
[Collection(nameof(RoslynServices))]
public class ProgramPipedInputTests
{
[Fact]
public async Task MainMethod_CollectedPipedInput_EvaluatesAndExitsSuccessfully()
{
// The interactive prompt path requires a real terminal; this test is only meaningful when
// stdin is redirected (which it is under the test host / CI). Bail out safely otherwise so
// we never block waiting for interactive input.
if (!Console.IsInputRedirected) return;

var originalIn = Console.In;
using var outputCollector = OutputCollector.Capture(out _);
try
{
Console.SetIn(new StringReader("1 + 1" + Environment.NewLine));

var exitCode = await Program.Main([]);

Assert.Equal(ExitCodes.Success, exitCode);
}
finally
{
Console.SetIn(originalIn);
}
}

[Fact]
public async Task MainMethod_StreamPipedInputWithoutRedirectedInput_ReturnsParseError()
{
// --streamPipedInput only makes sense with redirected stdin. When stdin is NOT redirected the
// program reports a configuration error and exits. (When the test host redirects stdin this
// instead streams the piped input, which is exercised by the collected-input test above.)
if (Console.IsInputRedirected) return;

using var outputCollector = OutputCollector.Capture(out _, out var capturedError);

var exitCode = await Program.Main(["--streamPipedInput"]);

Assert.Equal(ExitCodes.ErrorParseArguments, exitCode);
Assert.Contains("streamPipedInput", capturedError.ToString());
}
}
88 changes: 88 additions & 0 deletions CSharpRepl.Tests/PromptConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using CSharpRepl.Services.Roslyn;
using PrettyPrompt;
using PrettyPrompt.Consoles;
using PrettyPrompt.Highlighting;
using Xunit;

namespace CSharpRepl.Tests;
Expand Down Expand Up @@ -52,6 +53,55 @@ public async Task PromptConfiguration_Identation(bool shiftPressed)
Assert.Equal("\n\t", transformed.PastedText);
}

/// <summary>
/// The smart-indentation brace counter must ignore braces that appear inside comments, string
/// literals and char literals when deciding how far to indent the auto-inserted newline.
/// </summary>
[Theory]
[InlineData("if (true) { // }", "\n\t")] // '}' inside a single-line comment is ignored
[InlineData("if (true) { /* } */", "\n\t")] // '}' inside a multi-line comment is ignored
[InlineData("if (true) { var s = \"}\";", "\n\t")] // '}' inside a string literal is ignored
[InlineData("if (true) { var s = \"\\\"}\";", "\n\t")] // escaped quote then '}' inside a string is ignored
[InlineData("if (true) { var c = '}';", "\n\t")] // '}' inside a char literal is ignored
[InlineData("if (true) { if (false) {", "\n\t\t")] // nested open braces indent two levels
public async Task SmartIndentation_IgnoresBracesInsideCommentsStringsAndChars(string text, string expectedPastedText)
{
IPromptCallbacks configuration = new CSharpReplPromptCallbacks(console, services, new Configuration());
var enterKey = new KeyPress(new ConsoleKeyInfo('\0', ConsoleKey.Enter, shift: false, alt: false, control: false));

var transformed = await configuration.TransformKeyPressAsync(text, text.Length, enterKey, CancellationToken.None);

Assert.Equal(expectedPastedText, transformed.PastedText);
}

[Fact]
public async Task OpenAiCompletionKeyBinding_NoApiKey_ReturnsEmptyStreamingResult()
{
IPromptCallbacks configuration = new CSharpReplPromptCallbacks(console, services, new Configuration());
var ctrlAltSpace = new ConsoleKeyInfo(' ', ConsoleKey.Spacebar, shift: false, alt: true, control: true);
Assert.True(configuration.TryGetKeyPressCallbacks(ctrlAltSpace, out var callback));

// With no OpenAI API key configured the completion stream yields nothing, but the callback
// still returns a (streaming) result rather than throwing.
var result = await callback.Invoke("1 + ", 4, default);

Assert.NotNull(result);
}

[Fact]
public async Task DisassembleKeyBinding_InvalidCode_ReturnsErrorOutput()
{
IPromptCallbacks configuration = new CSharpReplPromptCallbacks(console, services, new Configuration());
var f9 = new ConsoleKeyInfo('\0', ConsoleKey.F9, shift: false, alt: false, control: false);
Assert.True(configuration.TryGetKeyPressCallbacks(f9, out var callback));

var result = await callback.Invoke("this is not valid c# !@#$", 0, default);

// The disassembler fails to compile, so the error branch returns the red error message.
Assert.NotNull(result);
Assert.False(string.IsNullOrEmpty(result!.Output));
}

public static IEnumerable<object[]> KeyPresses()
{
yield return new object[] { new ConsoleKeyInfo('\0', ConsoleKey.F1, shift: false, alt: false, control: false) };
Expand All @@ -61,4 +111,42 @@ public static IEnumerable<object[]> KeyPresses()
yield return new object[] { new ConsoleKeyInfo('\0', ConsoleKey.F12, shift: false, alt: false, control: false) };
yield return new object[] { new ConsoleKeyInfo('\0', ConsoleKey.D, shift: false, alt: false, control: true) };
}

[Theory]
[InlineData("help")]
[InlineData("#help")]
[InlineData("exit")]
[InlineData("clear")]
public async Task HighlightCallback_ReplKeyword_HighlightsTheWholeWord(string keyword)
{
var callbacks = new TestableCallbacks(console, services, new Configuration());

var spans = await callbacks.Highlight(keyword);

var span = Assert.Single(spans);
Assert.Equal(0, span.Start);
Assert.Equal(keyword.Length, span.Length);
}

[Fact]
public async Task HighlightCallback_RegularCode_DelegatesToRoslynClassification()
{
var callbacks = new TestableCallbacks(console, services, new Configuration());

// not a REPL keyword, so it must fall through to Roslyn syntax highlighting and produce
// more than a single whole-line span (e.g. the keyword, the type and the identifier).
var spans = await callbacks.Highlight("var x = 1;");

Assert.NotEmpty(spans);
}

/// <summary>Exposes the protected highlight callback so the REPL-keyword highlighting can be tested.</summary>
private sealed class TestableCallbacks : CSharpReplPromptCallbacks
{
public TestableCallbacks(IConsoleEx console, RoslynServices roslyn, Configuration configuration)
: base(console, roslyn, configuration) { }

public async Task<IReadOnlyCollection<FormatSpan>> Highlight(string text)
=> await HighlightCallbackAsync(text, default);
}
}
Loading