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("→"));
+ }
+}