diff --git a/src/McjCoderOrg.ClaudeAutoResume/ClaudeMonitor.cs b/src/McjCoderOrg.ClaudeAutoResume/ClaudeMonitor.cs index d1c0f3a..6b7a310 100644 --- a/src/McjCoderOrg.ClaudeAutoResume/ClaudeMonitor.cs +++ b/src/McjCoderOrg.ClaudeAutoResume/ClaudeMonitor.cs @@ -32,6 +32,20 @@ internal sealed class ClaudeMonitor : IClaudeMonitor private bool _disposed; private bool _potentialPromptDetected; + /// + /// Gets the current output buffer contents for testing. + /// + internal string OutputBufferContents + { + get + { + lock (_bufferLock) + { + return _outputBuffer.ToString(); + } + } + } + /// /// Initializes a new instance of the class. /// @@ -333,7 +347,7 @@ private async Task HandleHangingPromptAsync(CancellationToken ct) await _pty.WriterStream.FlushAsync(ct).ConfigureAwait(false); } - private static string EscapeForDisplay(string s) + internal static string EscapeForDisplay(string s) { return s.Replace("\n", "\\n", StringComparison.Ordinal) .Replace("\r", "\\r", StringComparison.Ordinal); @@ -408,7 +422,7 @@ private async Task ForwardInteractiveInputAsync(Stream stream, CancellationToken } } - private static byte[] ConvertKeyToBytes(ConsoleKeyInfo k) + internal static byte[] ConvertKeyToBytes(ConsoleKeyInfo k) { return k.Key switch { @@ -452,7 +466,7 @@ private async Task MonitorWindowSizeAsync(CancellationToken ct) } } - private void AppendToBuffer(string text) + internal void AppendToBuffer(string text) { lock (_bufferLock) { @@ -538,7 +552,7 @@ private async Task SendContinueCommandAsync(CancellationToken ct) await _pty.WriterStream.FlushAsync(ct).ConfigureAwait(false); } - private string? FindClaudeInPath() + internal string? FindClaudeInPath() { var pathVar = _environment.GetEnvironmentVariable("PATH") ?? string.Empty; var separator = _environment.IsWindows ? ';' : ':'; @@ -590,7 +604,7 @@ private async Task SendContinueCommandAsync(CancellationToken ct) return null; } - private Dictionary GetEnvironment() + internal Dictionary GetEnvironment() { var env = new Dictionary(_environment.GetEnvironmentVariables(), StringComparer.Ordinal); diff --git a/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature b/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature index d915162..d6bf62a 100644 --- a/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature +++ b/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature @@ -48,3 +48,26 @@ Feature: Rate Limit Detection | too many requests, please wait | | rate limit exceeded | | quota exceeded, limit reached | + + @unit + Scenario: Rate limit pattern split across buffer chunks + Given the output buffer contains "Your usage li" + And additional output arrives "mit has been reached" + When the rate limit check runs + Then a rate limit should be detected + + @unit + Scenario: Buffer rotation preserves recent rate limit message + Given the output buffer is at capacity + And new output contains "usage limit reached" + When the buffer rotates old content + And the rate limit check runs + Then the rate limit message is preserved + And a rate limit should be detected + + @unit + Scenario: Partial rate limit pattern not detected + Given the output buffer contains "limit" + But does not contain "reached" or "reset" + When the rate limit check runs + Then no rate limit should be detected diff --git a/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature.cs b/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature.cs index e3c5c5a..d65f386 100644 --- a/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature.cs +++ b/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/Features/RateLimitDetection.feature.cs @@ -117,7 +117,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/RateLimitDetection.feature.ndjson", 10); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/RateLimitDetection.feature.ndjson", 13); } async global::System.Threading.Tasks.Task global::Xunit.IAsyncLifetime.InitializeAsync() @@ -365,6 +365,138 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa await this.ScenarioCleanupAsync(); } + [global::Xunit.SkippableFactAttribute(DisplayName="Rate limit pattern split across buffer chunks")] + [global::Xunit.TraitAttribute("FeatureTitle", "Rate Limit Detection")] + [global::Xunit.TraitAttribute("Description", "Rate limit pattern split across buffer chunks")] + [global::Xunit.TraitAttribute("Category", "unit")] + public async global::System.Threading.Tasks.Task RateLimitPatternSplitAcrossBufferChunks() + { + string[] tagsOfScenario = new string[] { + "unit"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "8"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Rate limit pattern split across buffer chunks", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 53 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 7 + await this.FeatureBackgroundAsync(); +#line hidden +#line 54 + await testRunner.GivenAsync("the output buffer contains \"Your usage li\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 55 + await testRunner.AndAsync("additional output arrives \"mit has been reached\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 56 + await testRunner.WhenAsync("the rate limit check runs", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 57 + await testRunner.ThenAsync("a rate limit should be detected", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.SkippableFactAttribute(DisplayName="Buffer rotation preserves recent rate limit message")] + [global::Xunit.TraitAttribute("FeatureTitle", "Rate Limit Detection")] + [global::Xunit.TraitAttribute("Description", "Buffer rotation preserves recent rate limit message")] + [global::Xunit.TraitAttribute("Category", "unit")] + public async global::System.Threading.Tasks.Task BufferRotationPreservesRecentRateLimitMessage() + { + string[] tagsOfScenario = new string[] { + "unit"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "9"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Buffer rotation preserves recent rate limit message", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 60 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 7 + await this.FeatureBackgroundAsync(); +#line hidden +#line 61 + await testRunner.GivenAsync("the output buffer is at capacity", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 62 + await testRunner.AndAsync("new output contains \"usage limit reached\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 63 + await testRunner.WhenAsync("the buffer rotates old content", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 64 + await testRunner.AndAsync("the rate limit check runs", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 65 + await testRunner.ThenAsync("the rate limit message is preserved", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 66 + await testRunner.AndAsync("a rate limit should be detected", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.SkippableFactAttribute(DisplayName="Partial rate limit pattern not detected")] + [global::Xunit.TraitAttribute("FeatureTitle", "Rate Limit Detection")] + [global::Xunit.TraitAttribute("Description", "Partial rate limit pattern not detected")] + [global::Xunit.TraitAttribute("Category", "unit")] + public async global::System.Threading.Tasks.Task PartialRateLimitPatternNotDetected() + { + string[] tagsOfScenario = new string[] { + "unit"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "10"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Partial rate limit pattern not detected", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 69 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 7 + await this.FeatureBackgroundAsync(); +#line hidden +#line 70 + await testRunner.GivenAsync("the output buffer contains \"limit\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 71 + await testRunner.ButAsync("does not contain \"reached\" or \"reset\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "But "); +#line hidden +#line 72 + await testRunner.WhenAsync("the rate limit check runs", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 73 + await testRunner.ThenAsync("no rate limit should be detected", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class FixtureData : object, global::Xunit.IAsyncLifetime diff --git a/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/StepDefinitions/RateLimitDetectionSteps.cs b/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/StepDefinitions/RateLimitDetectionSteps.cs index 87ac2c6..86f3016 100644 --- a/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/StepDefinitions/RateLimitDetectionSteps.cs +++ b/tests/McjCoderOrg.ClaudeAutoResume.SystemTests/StepDefinitions/RateLimitDetectionSteps.cs @@ -58,6 +58,64 @@ public void GivenTheCooldownPeriodHasNotElapsed() _cooldownActive = true; } + [Given("additional output arrives {string}")] + public void GivenAdditionalOutputArrives(string content) + { + _output.WriteLine("Appending to buffer: '{0}'", content); + _bufferContent += content; + } + + [Given("the output buffer is at capacity")] + public void GivenTheOutputBufferIsAtCapacity() + { + // Fill buffer with placeholder content (simulating capacity) + _output.WriteLine("Setting buffer to capacity with placeholder content"); + _bufferContent = new string('X', 1000); + } + + [Given("new output contains {string}")] + public void GivenNewOutputContains(string content) + { + _output.WriteLine("Adding new output: '{0}'", content); + _bufferContent += content; + } + + [Given("does not contain {string} or {string}")] + public void GivenDoesNotContainEitherKeyword(string keyword1, string keyword2) + { + // Validation step - ensure buffer does not contain either keyword + var containsKeyword1 = _bufferContent.Contains(keyword1, StringComparison.OrdinalIgnoreCase); + var containsKeyword2 = _bufferContent.Contains(keyword2, StringComparison.OrdinalIgnoreCase); + (containsKeyword1 || containsKeyword2).Should().BeFalse( + "buffer should not contain '{0}' or '{1}'", keyword1, keyword2); + } + + [When("the buffer rotates old content")] + public void WhenTheBufferRotatesOldContent() + { + // Simulate buffer rotation by keeping only recent content + _output.WriteLine("Simulating buffer rotation"); + const int recentContentLength = 500; + if (_bufferContent.Length > recentContentLength) + { + _bufferContent = _bufferContent[^recentContentLength..]; + } + + _output.WriteLine("Buffer after rotation: '{0}'", _bufferContent); + } + + [Then("the rate limit message is preserved")] + public void ThenTheRateLimitMessageIsPreserved() + { + _output.WriteLine("Verifying rate limit message preserved in buffer"); + var hasRateLimitIndicator = + _bufferContent.Contains("limit", StringComparison.OrdinalIgnoreCase) && + (_bufferContent.Contains("reached", StringComparison.OrdinalIgnoreCase) || + _bufferContent.Contains("reset", StringComparison.OrdinalIgnoreCase) || + _bufferContent.Contains("exceeded", StringComparison.OrdinalIgnoreCase)); + hasRateLimitIndicator.Should().BeTrue("rate limit message should be preserved after rotation"); + } + [When("the rate limit check runs")] public void WhenTheRateLimitCheckRuns() { diff --git a/tests/McjCoderOrg.ClaudeAutoResume.Tests/ArgumentParserTests.cs b/tests/McjCoderOrg.ClaudeAutoResume.Tests/ArgumentParserTests.cs new file mode 100644 index 0000000..80bc25f --- /dev/null +++ b/tests/McjCoderOrg.ClaudeAutoResume.Tests/ArgumentParserTests.cs @@ -0,0 +1,385 @@ +using McjCoderOrg.ClaudeAutoResume.Services; + +namespace McjCoderOrg.ClaudeAutoResume; + +/// +/// Tests for the ArgumentParser class. +/// Verifies correct parsing of all CLI flags and arguments. +/// +public sealed class ArgumentParserTests +{ + private readonly ITestOutputHelper _output; + private readonly Mock _mockEnvironment; + + public ArgumentParserTests(ITestOutputHelper output) + { + _output = output; + _mockEnvironment = new Mock(MockBehavior.Loose); + + // Default setup - no environment variables + _mockEnvironment + .Setup(e => e.GetEnvironmentVariable(It.IsAny())) + .Returns((string?)null); + } + + private ArgumentParser CreateParser() => new(_mockEnvironment.Object); + + // Info flags (early exit) + + [Fact] + public void Parse_HelpFlag_ReturnsShowHelp() + { + var parser = CreateParser(); + + var result = parser.Parse(["--help"]); + + _output.WriteLine("Result: ShowHelp={0}", result.ShowHelp); + result.ShowHelp.Should().BeTrue(); + } + + [Fact] + public void Parse_HelpShortFlag_ReturnsShowHelp() + { + var parser = CreateParser(); + + var result = parser.Parse(["-h"]); + + _output.WriteLine("Result: ShowHelp={0}", result.ShowHelp); + result.ShowHelp.Should().BeTrue(); + } + + [Fact] + public void Parse_VersionFlag_ReturnsShowVersion() + { + var parser = CreateParser(); + + var result = parser.Parse(["--version"]); + + _output.WriteLine("Result: ShowVersion={0}", result.ShowVersion); + result.ShowVersion.Should().BeTrue(); + } + + [Fact] + public void Parse_VersionShortFlag_ReturnsShowVersion() + { + var parser = CreateParser(); + + var result = parser.Parse(["-v"]); + + _output.WriteLine("Result: ShowVersion={0}", result.ShowVersion); + result.ShowVersion.Should().BeTrue(); + } + + [Fact] + public void Parse_DiagnoseFlag_ReturnsShowDiagnose() + { + var parser = CreateParser(); + + var result = parser.Parse(["--diagnose"]); + + _output.WriteLine("Result: ShowDiagnose={0}", result.ShowDiagnose); + result.ShowDiagnose.Should().BeTrue(); + } + + // Boolean flags + + [Fact] + public void Parse_VerboseFlag_SetsVerbose() + { + var parser = CreateParser(); + + var result = parser.Parse(["--verbose"]); + + _output.WriteLine("Result: Verbose={0}", result.Verbose); + result.Verbose.Should().BeTrue(); + } + + [Fact] + public void Parse_VerboseShortFlag_WithCaseInsensitivity_MatchesVersionFirst() + { + // Note: Parser is case-insensitive, so -V (uppercase) matches -v (version) first + // because info flags are checked before boolean flags and cause early exit. + // This test documents that behavior. + var parser = CreateParser(); + + var result = parser.Parse(["-V"]); + + _output.WriteLine("Result: ShowVersion={0}, Verbose={1}", result.ShowVersion, result.Verbose); + // -V matches -v (version) due to case-insensitivity + result.ShowVersion.Should().BeTrue(); + result.Verbose.Should().BeFalse(); + } + + [Fact] + public void Parse_HeadlessFlag_SetsHeadless() + { + var parser = CreateParser(); + + var result = parser.Parse(["--headless"]); + + _output.WriteLine("Result: Headless={0}", result.Headless); + result.Headless.Should().BeTrue(); + } + + [Fact] + public void Parse_DangerousFlag_SetsDangerous() + { + var parser = CreateParser(); + + var result = parser.Parse(["--dangerous"]); + + _output.WriteLine("Result: Dangerous={0}", result.Dangerous); + result.Dangerous.Should().BeTrue(); + } + + [Fact] + public void Parse_DangerouslySkipPermissions_SetsDangerous() + { + var parser = CreateParser(); + + var result = parser.Parse(["--dangerously-skip-permissions"]); + + _output.WriteLine("Result: Dangerous={0}", result.Dangerous); + result.Dangerous.Should().BeTrue(); + } + + [Fact] + public void Parse_ContinueFlag_SetsContinueConversation() + { + var parser = CreateParser(); + + var result = parser.Parse(["--continue"]); + + _output.WriteLine("Result: ContinueConversation={0}", result.ContinueConversation); + result.ContinueConversation.Should().BeTrue(); + } + + [Fact] + public void Parse_ContinueShortFlag_SetsContinueConversation() + { + var parser = CreateParser(); + + var result = parser.Parse(["-c"]); + + _output.WriteLine("Result: ContinueConversation={0}", result.ContinueConversation); + result.ContinueConversation.Should().BeTrue(); + } + + // String arguments + + [Fact] + public void Parse_PromptWithValue_SetsInitialPrompt() + { + var parser = CreateParser(); + + var result = parser.Parse(["--prompt", "test prompt"]); + + _output.WriteLine("Result: InitialPrompt={0}", result.InitialPrompt); + result.InitialPrompt.Should().Be("test prompt"); + } + + [Fact] + public void Parse_PromptShortFlag_SetsInitialPrompt() + { + var parser = CreateParser(); + + var result = parser.Parse(["-p", "my prompt"]); + + _output.WriteLine("Result: InitialPrompt={0}", result.InitialPrompt); + result.InitialPrompt.Should().Be("my prompt"); + } + + [Fact] + public void Parse_PromptWithoutValue_ReturnsError() + { + var parser = CreateParser(); + + var result = parser.Parse(["--prompt"]); + + _output.WriteLine("Result: ErrorMessage={0}", result.ErrorMessage); + result.ErrorMessage.Should().Contain("--prompt requires an argument"); + } + + // Integer arguments + + [Fact] + public void Parse_WaitWithValue_SetsWaitMinutes() + { + var parser = CreateParser(); + + var result = parser.Parse(["--wait", "30"]); + + _output.WriteLine("Result: WaitMinutes={0}", result.WaitMinutes); + result.WaitMinutes.Should().Be(30); + } + + [Fact] + public void Parse_WaitShortFlag_SetsWaitMinutes() + { + var parser = CreateParser(); + + var result = parser.Parse(["-w", "15"]); + + _output.WriteLine("Result: WaitMinutes={0}", result.WaitMinutes); + result.WaitMinutes.Should().Be(15); + } + + [Fact] + public void Parse_WaitWithoutValue_ReturnsError() + { + var parser = CreateParser(); + + var result = parser.Parse(["--wait"]); + + _output.WriteLine("Result: ErrorMessage={0}", result.ErrorMessage); + result.ErrorMessage.Should().Contain("--wait requires"); + } + + [Fact] + public void Parse_WaitWithInvalidNumber_ReturnsError() + { + var parser = CreateParser(); + + var result = parser.Parse(["--wait", "notanumber"]); + + _output.WriteLine("Result: ErrorMessage={0}", result.ErrorMessage); + result.ErrorMessage.Should().Contain("--wait requires"); + } + + // Environment fallback + + [Fact] + public void Parse_NoWaitFlag_UsesEnvironmentVariable() + { + _mockEnvironment + .Setup(e => e.GetEnvironmentVariable("CLAUDE_WAIT_MINUTES")) + .Returns("25"); + var parser = CreateParser(); + + var result = parser.Parse([]); + + _output.WriteLine("Result: WaitMinutes={0}", result.WaitMinutes); + result.WaitMinutes.Should().Be(25); + } + + [Fact] + public void Parse_WaitFlag_OverridesEnvironmentVariable() + { + _mockEnvironment + .Setup(e => e.GetEnvironmentVariable("CLAUDE_WAIT_MINUTES")) + .Returns("25"); + var parser = CreateParser(); + + var result = parser.Parse(["--wait", "10"]); + + _output.WriteLine("Result: WaitMinutes={0}", result.WaitMinutes); + result.WaitMinutes.Should().Be(10); + } + + [Fact] + public void Parse_InvalidEnvironmentVariable_ReturnsNull() + { + _mockEnvironment + .Setup(e => e.GetEnvironmentVariable("CLAUDE_WAIT_MINUTES")) + .Returns("invalid"); + var parser = CreateParser(); + + var result = parser.Parse([]); + + _output.WriteLine("Result: WaitMinutes={0}", result.WaitMinutes); + result.WaitMinutes.Should().BeNull(); + } + + // Pass-through arguments + + [Fact] + public void Parse_UnknownArgs_AddedToClaudeArgs() + { + var parser = CreateParser(); + + var result = parser.Parse(["--model", "claude-3-opus"]); + + _output.WriteLine("Result: ClaudeArgs=[{0}]", string.Join(", ", result.ClaudeArgs)); + result.ClaudeArgs.Should().BeEquivalentTo("--model", "claude-3-opus"); + } + + [Fact] + public void Parse_MixedArgs_CorrectlySeparated() + { + var parser = CreateParser(); + + var result = parser.Parse(["--verbose", "--model", "opus", "-p", "test"]); + + _output.WriteLine("Result: Verbose={0}, InitialPrompt={1}, ClaudeArgs=[{2}]", + result.Verbose, result.InitialPrompt, string.Join(", ", result.ClaudeArgs)); + result.Verbose.Should().BeTrue(); + result.InitialPrompt.Should().Be("test"); + result.ClaudeArgs.Should().BeEquivalentTo("--model", "opus"); + } + + // Edge cases + + [Fact] + public void Parse_EmptyArgs_ReturnsDefaultResult() + { + var parser = CreateParser(); + + var result = parser.Parse([]); + + _output.WriteLine("Result: all properties default"); + result.ShowHelp.Should().BeFalse(); + result.ShowVersion.Should().BeFalse(); + result.ShowDiagnose.Should().BeFalse(); + result.Verbose.Should().BeFalse(); + result.Headless.Should().BeFalse(); + result.Dangerous.Should().BeFalse(); + result.ContinueConversation.Should().BeFalse(); + result.InitialPrompt.Should().BeNull(); + result.WaitMinutes.Should().BeNull(); + result.ClaudeArgs.Should().BeEmpty(); + result.ErrorMessage.Should().BeNull(); + } + + [Theory] + [InlineData("--HELP")] + [InlineData("--Help")] + [InlineData("--help")] + public void Parse_CaseInsensitiveFlags_Recognized(string flag) + { + var parser = CreateParser(); + + var result = parser.Parse([flag]); + + _output.WriteLine("Flag '{0}' -> ShowHelp={1}", flag, result.ShowHelp); + result.ShowHelp.Should().BeTrue(); + } + + [Fact] + public void Parse_MultipleFlags_AllRecognized() + { + var parser = CreateParser(); + + var result = parser.Parse(["--verbose", "--headless", "--dangerous", "-c"]); + + _output.WriteLine("Result: Verbose={0}, Headless={1}, Dangerous={2}, Continue={3}", + result.Verbose, result.Headless, result.Dangerous, result.ContinueConversation); + result.Verbose.Should().BeTrue(); + result.Headless.Should().BeTrue(); + result.Dangerous.Should().BeTrue(); + result.ContinueConversation.Should().BeTrue(); + } + + [Fact] + public void Parse_InfoFlagStopsParsingEarly() + { + var parser = CreateParser(); + + // --help appears first - should stop and return immediately + var result = parser.Parse(["--help", "--verbose"]); + + _output.WriteLine("Result: ShowHelp={0}, Verbose={1}", result.ShowHelp, result.Verbose); + result.ShowHelp.Should().BeTrue(); + // Verbose is false because parsing stopped at --help + result.Verbose.Should().BeFalse(); + } +} diff --git a/tests/McjCoderOrg.ClaudeAutoResume.Tests/ClaudeMonitorHelperTests.cs b/tests/McjCoderOrg.ClaudeAutoResume.Tests/ClaudeMonitorHelperTests.cs new file mode 100644 index 0000000..520c095 --- /dev/null +++ b/tests/McjCoderOrg.ClaudeAutoResume.Tests/ClaudeMonitorHelperTests.cs @@ -0,0 +1,327 @@ +using McjCoderOrg.ClaudeAutoResume.Services; + +namespace McjCoderOrg.ClaudeAutoResume; + +/// +/// Tests for helper methods in ClaudeMonitor. +/// Covers EscapeForDisplay, FindClaudeInPath, GetEnvironment, and AppendToBuffer. +/// +public sealed class ClaudeMonitorHelperTests : IDisposable +{ + private readonly ITestOutputHelper _output; + private readonly Mock _mockConsole; + private readonly Mock _mockEnvironment; + private ClaudeMonitor? _monitor; + + public ClaudeMonitorHelperTests(ITestOutputHelper output) + { + _output = output; + _mockConsole = new Mock(MockBehavior.Loose); + _mockEnvironment = new Mock(MockBehavior.Loose); + + // Setup default mock behavior + _mockConsole.Setup(c => c.WindowWidth).Returns(value: 120); + _mockConsole.Setup(c => c.WindowHeight).Returns(value: 30); + _mockEnvironment.Setup(e => e.CurrentDirectory).Returns(value: Environment.CurrentDirectory); + _mockEnvironment.Setup(e => e.GetEnvironmentVariables()).Returns( + value: new Dictionary(StringComparer.Ordinal)); + } + + public void Dispose() + { + _monitor?.Dispose(); + } + + private ClaudeMonitor CreateMonitor(WrapperConfig? config = null) + { + _monitor = new ClaudeMonitor( + config ?? WrapperConfig.Default, + _mockConsole.Object, + _mockEnvironment.Object); + return _monitor; + } + + // EscapeForDisplay tests + + [Fact] + public void EscapeForDisplay_NewlineCharacter_ReturnsBackslashN() + { + var result = ClaudeMonitor.EscapeForDisplay("hello\nworld"); + + _output.WriteLine("Result: {0}", result); + result.Should().Be("hello\\nworld"); + } + + [Fact] + public void EscapeForDisplay_CarriageReturn_ReturnsBackslashR() + { + var result = ClaudeMonitor.EscapeForDisplay("hello\rworld"); + + _output.WriteLine("Result: {0}", result); + result.Should().Be("hello\\rworld"); + } + + [Fact] + public void EscapeForDisplay_MixedEscapes_ReplacesAll() + { + var result = ClaudeMonitor.EscapeForDisplay("line1\r\nline2\nline3"); + + _output.WriteLine("Result: {0}", result); + result.Should().Be("line1\\r\\nline2\\nline3"); + } + + [Fact] + public void EscapeForDisplay_NoEscapes_ReturnsOriginal() + { + var result = ClaudeMonitor.EscapeForDisplay("hello world"); + + _output.WriteLine("Result: {0}", result); + result.Should().Be("hello world"); + } + + [Fact] + public void EscapeForDisplay_EmptyString_ReturnsEmpty() + { + var result = ClaudeMonitor.EscapeForDisplay(string.Empty); + + _output.WriteLine("Result: [{0}]", result); + result.Should().BeEmpty(); + } + + // FindClaudeInPath tests + + [Fact] + public void FindClaudeInPath_InSystemPath_ReturnsPath() + { + // Unix uses : as path separator + _mockEnvironment.Setup(e => e.GetEnvironmentVariable("PATH")).Returns(value: "/usr/bin:/usr/local/bin"); + _mockEnvironment.Setup(e => e.IsWindows).Returns(value: false); + _mockEnvironment.Setup(e => e.UserProfile).Returns(value: "/home/user"); + // Use Path.Combine to match what the code generates on any platform + var claudePath = Path.Combine("/usr/local/bin", "claude"); + _mockEnvironment.Setup(e => e.FileExists(claudePath)).Returns(value: true); + var monitor = CreateMonitor(); + + var result = monitor.FindClaudeInPath(); + + _output.WriteLine("Result: {0}", result); + result.Should().Be(claudePath); + } + + [Fact] + public void FindClaudeInPath_Windows_ChecksClaudeCmd() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariable("PATH")).Returns(value: "C:\\Windows;C:\\npm"); + _mockEnvironment.Setup(e => e.IsWindows).Returns(value: true); + _mockEnvironment.Setup(e => e.UserProfile).Returns(value: "C:\\Users\\test"); + // Use Path.Combine to match what the code generates on any platform + var claudeCmdPath = Path.Combine("C:\\npm", "claude.cmd"); + _mockEnvironment.Setup(e => e.FileExists(claudeCmdPath)).Returns(value: true); + var monitor = CreateMonitor(); + + var result = monitor.FindClaudeInPath(); + + _output.WriteLine("Result: {0}", result); + result.Should().Be(claudeCmdPath); + } + + [Fact] + public void FindClaudeInPath_Windows_ChecksClaudeExe() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariable("PATH")).Returns(value: "C:\\Windows;C:\\npm"); + _mockEnvironment.Setup(e => e.IsWindows).Returns(value: true); + _mockEnvironment.Setup(e => e.UserProfile).Returns(value: "C:\\Users\\test"); + // Use Path.Combine to match what the code generates on any platform + var claudeCmdPath = Path.Combine("C:\\npm", "claude.cmd"); + var claudeExePath = Path.Combine("C:\\npm", "claude.exe"); + _mockEnvironment.Setup(e => e.FileExists(claudeCmdPath)).Returns(value: false); + _mockEnvironment.Setup(e => e.FileExists(claudeExePath)).Returns(value: true); + var monitor = CreateMonitor(); + + var result = monitor.FindClaudeInPath(); + + _output.WriteLine("Result: {0}", result); + result.Should().Be(claudeExePath); + } + + [Fact] + public void FindClaudeInPath_NotInPath_ChecksNpmGlobal() + { + // Unix uses : as path separator + _mockEnvironment.Setup(e => e.GetEnvironmentVariable("PATH")).Returns(value: "/usr/bin"); + _mockEnvironment.Setup(e => e.IsWindows).Returns(value: false); + _mockEnvironment.Setup(e => e.UserProfile).Returns(value: "/home/user"); + // First the PATH check returns false - use Path.Combine to match what the code generates + _mockEnvironment.Setup(e => e.FileExists(Path.Combine("/usr/bin", "claude"))).Returns(value: false); + // Then npm-global check returns true - Path.Combine generates platform-specific paths + var npmGlobalPath = Path.Combine("/home/user", ".npm-global", "bin", "claude"); + _mockEnvironment.Setup(e => e.FileExists(npmGlobalPath)).Returns(value: true); + var monitor = CreateMonitor(); + + var result = monitor.FindClaudeInPath(); + + _output.WriteLine("Result: {0}", result ?? "null"); + result.Should().Be(npmGlobalPath); + } + + [Fact] + public void FindClaudeInPath_NotFound_ReturnsNull() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariable("PATH")).Returns(value: "/usr/bin"); + _mockEnvironment.Setup(e => e.IsWindows).Returns(value: false); + _mockEnvironment.Setup(e => e.UserProfile).Returns(value: "/home/user"); + _mockEnvironment.Setup(e => e.FileExists(It.IsAny())).Returns(value: false); + var monitor = CreateMonitor(); + + var result = monitor.FindClaudeInPath(); + + _output.WriteLine("Result: {0}", result ?? "null"); + result.Should().BeNull(); + } + + [Fact] + public void FindClaudeInPath_EmptyPath_ReturnsNull() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariable("PATH")).Returns(value: string.Empty); + _mockEnvironment.Setup(e => e.IsWindows).Returns(value: false); + _mockEnvironment.Setup(e => e.UserProfile).Returns(value: "/home/user"); + _mockEnvironment.Setup(e => e.FileExists(It.IsAny())).Returns(value: false); + var monitor = CreateMonitor(); + + var result = monitor.FindClaudeInPath(); + + _output.WriteLine("Result: {0}", result ?? "null"); + result.Should().BeNull(); + } + + // GetEnvironment tests + + [Fact] + public void GetEnvironment_NoTerm_SetsXterm256Color() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariables()).Returns( + value: new Dictionary(StringComparer.Ordinal) + { + ["PATH"] = "/usr/bin", + }); + var monitor = CreateMonitor(); + + var result = monitor.GetEnvironment(); + + _output.WriteLine("TERM: {0}", result["TERM"]); + result["TERM"].Should().Be("xterm-256color"); + } + + [Fact] + public void GetEnvironment_ExistingTerm_PreservesValue() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariables()).Returns( + value: new Dictionary(StringComparer.Ordinal) + { + ["TERM"] = "vt100", + }); + var monitor = CreateMonitor(); + + var result = monitor.GetEnvironment(); + + _output.WriteLine("TERM: {0}", result["TERM"]); + result["TERM"].Should().Be("vt100"); + } + + [Fact] + public void GetEnvironment_EmptyTerm_SetsXterm256Color() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariables()).Returns( + value: new Dictionary(StringComparer.Ordinal) + { + ["TERM"] = string.Empty, + }); + var monitor = CreateMonitor(); + + var result = monitor.GetEnvironment(); + + _output.WriteLine("TERM: {0}", result["TERM"]); + result["TERM"].Should().Be("xterm-256color"); + } + + [Fact] + public void GetEnvironment_CopiesAllEnvironmentVariables() + { + _mockEnvironment.Setup(e => e.GetEnvironmentVariables()).Returns( + value: new Dictionary(StringComparer.Ordinal) + { + ["PATH"] = "/usr/bin", + ["HOME"] = "/home/user", + ["CUSTOM_VAR"] = "custom_value", + }); + var monitor = CreateMonitor(); + + var result = monitor.GetEnvironment(); + + _output.WriteLine("Environment keys: {0}", string.Join(", ", result.Keys)); + result.Should().ContainKey("PATH"); + result.Should().ContainKey("HOME"); + result.Should().ContainKey("CUSTOM_VAR"); + result["PATH"].Should().Be("/usr/bin"); + result["HOME"].Should().Be("/home/user"); + result["CUSTOM_VAR"].Should().Be("custom_value"); + } + + // AppendToBuffer tests + + [Fact] + public void AppendToBuffer_UnderLimit_AppendsAll() + { + var config = WrapperConfig.Default with { OutputBufferSize = 1000 }; + var monitor = CreateMonitor(config); + + monitor.AppendToBuffer("Hello "); + monitor.AppendToBuffer("World"); + + _output.WriteLine("Buffer contents: {0}", monitor.OutputBufferContents); + monitor.OutputBufferContents.Should().Be("Hello World"); + } + + [Fact] + public void AppendToBuffer_OverLimit_TruncatesOldContent() + { + var config = WrapperConfig.Default with { OutputBufferSize = 10 }; + var monitor = CreateMonitor(config); + + monitor.AppendToBuffer("12345678901234567890"); // 20 chars, exceeds buffer of 10 + + _output.WriteLine("Buffer contents: {0}", monitor.OutputBufferContents); + monitor.OutputBufferContents.Should().HaveLength(10); + // Should keep the last 10 characters + monitor.OutputBufferContents.Should().Be("1234567890"); + } + + [Fact] + public void AppendToBuffer_ExactLimit_NoTruncation() + { + var config = WrapperConfig.Default with { OutputBufferSize = 10 }; + var monitor = CreateMonitor(config); + + monitor.AppendToBuffer("1234567890"); // Exactly 10 chars + + _output.WriteLine("Buffer contents: {0}", monitor.OutputBufferContents); + monitor.OutputBufferContents.Should().Be("1234567890"); + } + + [Fact] + public void AppendToBuffer_MultipleAppends_TruncatesCorrectly() + { + var config = WrapperConfig.Default with { OutputBufferSize = 10 }; + var monitor = CreateMonitor(config); + + monitor.AppendToBuffer("aaaaa"); // 5 chars + monitor.AppendToBuffer("bbbbb"); // 5 more = 10 total + monitor.AppendToBuffer("ccccc"); // 5 more = 15 total, truncate to 10 + + _output.WriteLine("Buffer contents: {0}", monitor.OutputBufferContents); + monitor.OutputBufferContents.Should().HaveLength(10); + // Should keep the last 10 characters + // cspell:disable-next-line + monitor.OutputBufferContents.Should().Be("bbbbbccccc"); + } +} diff --git a/tests/McjCoderOrg.ClaudeAutoResume.Tests/ClaudeMonitorKeyConversionTests.cs b/tests/McjCoderOrg.ClaudeAutoResume.Tests/ClaudeMonitorKeyConversionTests.cs new file mode 100644 index 0000000..08ffad1 --- /dev/null +++ b/tests/McjCoderOrg.ClaudeAutoResume.Tests/ClaudeMonitorKeyConversionTests.cs @@ -0,0 +1,355 @@ +using System.Text; + +namespace McjCoderOrg.ClaudeAutoResume; + +/// +/// Tests for the ConvertKeyToBytes method in ClaudeMonitor. +/// Verifies correct byte sequences for all supported key types. +/// +public sealed class ClaudeMonitorKeyConversionTests +{ + private readonly ITestOutputHelper _output; + + public ClaudeMonitorKeyConversionTests(ITestOutputHelper output) + { + _output = output; + } + + // Basic keys + + [Fact] + public void ConvertKeyToBytes_Enter_ReturnsCarriageReturn() + { + var key = new ConsoleKeyInfo( + keyChar: '\r', + key: ConsoleKey.Enter, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Enter key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x0D }); // \r + } + + [Fact] + public void ConvertKeyToBytes_Tab_ReturnsTabCharacter() + { + var key = new ConsoleKeyInfo( + keyChar: '\t', + key: ConsoleKey.Tab, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Tab key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x09 }); // \t + } + + [Fact] + public void ConvertKeyToBytes_Backspace_Returns0x7F() + { + var key = new ConsoleKeyInfo( + keyChar: '\b', + key: ConsoleKey.Backspace, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Backspace key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x7F }); + } + + [Fact] + public void ConvertKeyToBytes_Escape_Returns0x1B() + { + var key = new ConsoleKeyInfo( + keyChar: (char)0x1B, + key: ConsoleKey.Escape, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Escape key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B }); + } + + // Arrow keys + + [Fact] + public void ConvertKeyToBytes_UpArrow_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.UpArrow, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("UpArrow key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'A' }); // ESC[A + } + + [Fact] + public void ConvertKeyToBytes_DownArrow_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.DownArrow, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("DownArrow key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'B' }); // ESC[B + } + + [Fact] + public void ConvertKeyToBytes_RightArrow_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.RightArrow, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("RightArrow key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'C' }); // ESC[C + } + + [Fact] + public void ConvertKeyToBytes_LeftArrow_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.LeftArrow, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("LeftArrow key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'D' }); // ESC[D + } + + // Navigation keys + + [Fact] + public void ConvertKeyToBytes_Home_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.Home, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Home key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'H' }); // ESC[H + } + + [Fact] + public void ConvertKeyToBytes_End_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.End, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("End key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'F' }); // ESC[F + } + + [Fact] + public void ConvertKeyToBytes_Delete_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.Delete, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Delete key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'3', (byte)'~' }); // ESC[3~ + } + + [Fact] + public void ConvertKeyToBytes_PageUp_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.PageUp, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("PageUp key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'5', (byte)'~' }); // ESC[5~ + } + + [Fact] + public void ConvertKeyToBytes_PageDown_ReturnsEscapeSequence() + { + var key = new ConsoleKeyInfo( + keyChar: '\0', + key: ConsoleKey.PageDown, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("PageDown key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x1B, (byte)'[', (byte)'6', (byte)'~' }); // ESC[6~ + } + + // Ctrl combinations + + [Fact] + public void ConvertKeyToBytes_CtrlC_Returns0x03() + { + var key = new ConsoleKeyInfo( + keyChar: (char)0x03, + key: ConsoleKey.C, + shift: false, + alt: false, + control: true); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Ctrl+C key bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { 0x03 }); // ETX (End of Text) + } + + [Theory] + [InlineData(ConsoleKey.A, 0x01)] + [InlineData(ConsoleKey.B, 0x02)] + [InlineData(ConsoleKey.C, 0x03)] + [InlineData(ConsoleKey.D, 0x04)] + [InlineData(ConsoleKey.E, 0x05)] + [InlineData(ConsoleKey.F, 0x06)] + [InlineData(ConsoleKey.G, 0x07)] + [InlineData(ConsoleKey.H, 0x08)] + [InlineData(ConsoleKey.I, 0x09)] + [InlineData(ConsoleKey.J, 0x0A)] + [InlineData(ConsoleKey.K, 0x0B)] + [InlineData(ConsoleKey.L, 0x0C)] + [InlineData(ConsoleKey.M, 0x0D)] + [InlineData(ConsoleKey.N, 0x0E)] + [InlineData(ConsoleKey.O, 0x0F)] + [InlineData(ConsoleKey.P, 0x10)] + [InlineData(ConsoleKey.Q, 0x11)] + [InlineData(ConsoleKey.R, 0x12)] + [InlineData(ConsoleKey.S, 0x13)] + [InlineData(ConsoleKey.T, 0x14)] + [InlineData(ConsoleKey.U, 0x15)] + [InlineData(ConsoleKey.V, 0x16)] + [InlineData(ConsoleKey.W, 0x17)] + [InlineData(ConsoleKey.X, 0x18)] + [InlineData(ConsoleKey.Y, 0x19)] + [InlineData(ConsoleKey.Z, 0x1A)] + public void ConvertKeyToBytes_CtrlLetters_ReturnsControlCodes(ConsoleKey key, byte expected) + { + var keyInfo = new ConsoleKeyInfo( + keyChar: (char)expected, + key: key, + shift: false, + alt: false, + control: true); + + var result = ClaudeMonitor.ConvertKeyToBytes(keyInfo); + + _output.WriteLine("Ctrl+{0} key bytes: [{1}]", key, string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(new byte[] { expected }); + } + + // Regular characters + + [Theory] + [InlineData('a', "a")] + [InlineData('z', "z")] + [InlineData('A', "A")] + [InlineData('Z', "Z")] + [InlineData('0', "0")] + [InlineData('9', "9")] + [InlineData(' ', " ")] + [InlineData('!', "!")] + [InlineData('@', "@")] + [InlineData('#', "#")] + public void ConvertKeyToBytes_PrintableCharacters_ReturnsUtf8Bytes(char c, string expected) + { + // Use a generic key that won't match special cases + var key = new ConsoleKeyInfo( + keyChar: c, + key: ConsoleKey.NoName, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Character '{0}' bytes: [{1}]", c, string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(Encoding.UTF8.GetBytes(expected)); + } + + [Fact] + public void ConvertKeyToBytes_UnicodeCharacter_ReturnsCorrectUtf8() + { + // Test with a multi-byte UTF-8 character (e.g., é = U+00E9) + var key = new ConsoleKeyInfo( + keyChar: 'é', + key: ConsoleKey.NoName, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Unicode 'é' bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(Encoding.UTF8.GetBytes("é")); + result.Should().HaveCount(2); // é is 2 bytes in UTF-8 + } + + [Fact] + public void ConvertKeyToBytes_EmojiCharacter_ReturnsCorrectUtf8() + { + // Test with a 4-byte UTF-8 character + var key = new ConsoleKeyInfo( + keyChar: '→', + key: ConsoleKey.NoName, + shift: false, + alt: false, + control: false); + + var result = ClaudeMonitor.ConvertKeyToBytes(key); + + _output.WriteLine("Unicode '→' bytes: [{0}]", string.Join(", ", result.Select(b => $"0x{b:X2}"))); + result.Should().BeEquivalentTo(Encoding.UTF8.GetBytes("→")); + } +}