summaryrefslogtreecommitdiff
path: root/internal/repl
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-23 20:54:36 +0300
committerPaul Buetow <paul@buetow.org>2026-05-23 20:54:36 +0300
commit174b313c5ea475ff0baadf409f226d3e7b65738a (patch)
tree50e8503294a02faec07366e5fbef8aba2c4edbe7 /internal/repl
parent54de2315861cdac384183623e1b466f83f397dfc (diff)
test: add unit tests for REPL subsystems
Add dedicated unit tests for four REPL components that lacked test coverage: - SignalHandler (signal_test.go): 5 tests covering constructor, Stop() without Start, callback execution, goroutine semantics, and single-shot signal handling behavior - TTYChecker (tty_test.go): 5 tests covering EnsureTTY error in non-TTY context, error message content, IsTTY return value, IsTTY/EnsureTTY consistency, and idempotent behavior - HistoryManager (history_test.go): 11 tests covering constructor, Path() with default and custom baseDir, Save/Load roundtrip, maxEntries truncation, non-existent file, empty file/slice, special characters, and file overwrite behavior. Added WithBaseDir() method to enable testing with temp directories. - AutoCompleteAdapter (completer_adapter_test.go): 5 tests covering Do() with empty/whitespace input, exact/partial/no matches, case-insensitive matching, multi-word completion, cursor position, common prefix calculation, and command order preservation
Diffstat (limited to 'internal/repl')
-rw-r--r--internal/repl/completer_adapter_test.go290
-rw-r--r--internal/repl/history.go29
-rw-r--r--internal/repl/history_test.go222
-rw-r--r--internal/repl/signal_test.go118
-rw-r--r--internal/repl/tty_test.go70
5 files changed, 723 insertions, 6 deletions
diff --git a/internal/repl/completer_adapter_test.go b/internal/repl/completer_adapter_test.go
new file mode 100644
index 0000000..397a036
--- /dev/null
+++ b/internal/repl/completer_adapter_test.go
@@ -0,0 +1,290 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package repl
+
+import (
+ "testing"
+)
+
+func TestNewAutoCompleter(t *testing.T) {
+ adapter := NewAutoCompleter()
+ if adapter == nil {
+ t.Fatal("NewAutoCompleter returned nil")
+ }
+ if adapter.commands == nil {
+ t.Fatal("AutoCompleteAdapter.commands is nil")
+ }
+ expectedCommands := Commands()
+ if len(adapter.commands) != len(expectedCommands) {
+ t.Errorf("commands count = %d, want %d", len(adapter.commands), len(expectedCommands))
+ }
+}
+
+func TestAutoCompleteAdapterDo(t *testing.T) {
+ adapter := NewAutoCompleter()
+ commands := Commands()
+
+ tests := []struct {
+ name string
+ line []rune
+ pos int
+ wantLen int
+ wantMinLen int
+ description string
+ }{
+ // Empty / whitespace input returns all commands
+ {
+ name: "empty input returns all commands",
+ line: []rune(""),
+ pos: 0,
+ wantLen: len(commands),
+ wantMinLen: 0,
+ },
+ {
+ name: "whitespace only returns all commands",
+ line: []rune(" "),
+ pos: 3,
+ wantLen: len(commands),
+ wantMinLen: 0,
+ },
+ // Exact matches
+ {
+ name: "exact match help",
+ line: []rune("help"),
+ pos: 4,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ {
+ name: "exact match clear",
+ line: []rune("clear"),
+ pos: 5,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ // Partial matches (single match, commonLen always 0 since minLen capped at len(lastWord))
+ {
+ name: "partial match he",
+ line: []rune("he"),
+ pos: 2,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ {
+ name: "partial match cl",
+ line: []rune("cl"),
+ pos: 2,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ {
+ name: "partial match q",
+ line: []rune("q"),
+ pos: 1,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ {
+ name: "partial match rp",
+ line: []rune("rp"),
+ pos: 2,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ // Multiple matches
+ {
+ name: "partial match c matches calc and clear",
+ line: []rune("c"),
+ pos: 1,
+ wantLen: 2,
+ wantMinLen: 0,
+ },
+ // No matches
+ {
+ name: "no match xyz",
+ line: []rune("xyz"),
+ pos: 3,
+ wantLen: 0,
+ wantMinLen: 0,
+ },
+ {
+ name: "no match numbers",
+ line: []rune("123"),
+ pos: 3,
+ wantLen: 0,
+ wantMinLen: 0,
+ },
+ {
+ name: "no match symbols",
+ line: []rune("!@#"),
+ pos: 3,
+ wantLen: 0,
+ wantMinLen: 0,
+ },
+ {
+ name: "no match too long prefix",
+ line: []rune("heloooooo"),
+ pos: 9,
+ wantLen: 0,
+ wantMinLen: 0,
+ },
+ // Case insensitive
+ {
+ name: "uppercase HELP",
+ line: []rune("HELP"),
+ pos: 4,
+ wantLen: 1,
+ wantMinLen: -4, // HELP vs help: no case match in prefix calc
+ },
+ {
+ name: "uppercase CLEAR",
+ line: []rune("CLEAR"),
+ pos: 5,
+ wantLen: 1,
+ wantMinLen: -5,
+ },
+ {
+ name: "mixed case HeLp",
+ line: []rune("HeLp"),
+ pos: 4,
+ wantLen: 1,
+ wantMinLen: -4,
+ },
+ {
+ name: "uppercase Q matches quit",
+ line: []rune("Q"),
+ pos: 1,
+ wantLen: 1,
+ wantMinLen: -1,
+ },
+ {
+ name: "uppercase C matches calc and clear",
+ line: []rune("C"),
+ pos: 1,
+ wantLen: 2,
+ wantMinLen: -1,
+ },
+ // Multi-word input completes last word
+ {
+ name: "multi word input completes last word",
+ line: []rune("rpn help"),
+ pos: 8,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ // Cursor position matters
+ {
+ name: "cursor in middle of word",
+ line: []rune("help"),
+ pos: 2,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ // Single character prefixes
+ {
+ name: "single char h",
+ line: []rune("h"),
+ pos: 1,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ {
+ name: "single char e matches exit",
+ line: []rune("e"),
+ pos: 1,
+ wantLen: 1,
+ wantMinLen: 0,
+ },
+ {
+ name: "single char r matches rpn and rat",
+ line: []rune("r"),
+ pos: 1,
+ wantLen: 2,
+ wantMinLen: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ matches, commonLen := adapter.Do(tt.line, tt.pos)
+
+ if len(matches) != tt.wantLen {
+ t.Errorf("got %d matches, want %d. matches: %v",
+ len(matches), tt.wantLen, runeSliceToStringSlice(matches))
+ }
+
+ if commonLen != tt.wantMinLen {
+ t.Errorf("common prefix len = %d, want %d", commonLen, tt.wantMinLen)
+ }
+
+ // Verify all matches are actual commands
+ for _, m := range matches {
+ matchStr := string(m)
+ found := false
+ for _, cmd := range commands {
+ if cmd == matchStr {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("match %q is not a valid command", matchStr)
+ }
+ }
+ })
+ }
+}
+
+func TestAutoCompleteAdapterDoPreserveCommandOrder(t *testing.T) {
+ adapter := NewAutoCompleter()
+ // 'c' matches calc and clear; they should appear in Commands() order
+ matches, _ := adapter.Do([]rune("c"), 1)
+ if len(matches) != 2 {
+ t.Fatalf("expected 2 matches, got %d", len(matches))
+ }
+
+ cmds := Commands()
+ calcIdx, clearIdx := -1, -1
+ for i, cmd := range cmds {
+ if cmd == "calc" {
+ calcIdx = i
+ }
+ if cmd == "clear" {
+ clearIdx = i
+ }
+ }
+
+ match0 := string(matches[0])
+ match1 := string(matches[1])
+
+ if calcIdx < clearIdx {
+ if match0 != "calc" || match1 != "clear" {
+ t.Errorf("expected [calc, clear], got [%s, %s]", match0, match1)
+ }
+ } else {
+ if match0 != "clear" || match1 != "calc" {
+ t.Errorf("expected [clear, calc], got [%s, %s]", match0, match1)
+ }
+ }
+}
+
+func TestAutoCompleteAdapterDoMultilineInput(t *testing.T) {
+ adapter := NewAutoCompleter()
+
+ // Tab-separated words
+ matches, _ := adapter.Do([]rune("rpn\thelp"), 8)
+ if len(matches) != 1 {
+ t.Errorf("tab-separated 'rpn\\thelp' should match 'help', got %d: %v",
+ len(matches), runeSliceToStringSlice(matches))
+ }
+}
+
+// Helper function to convert [][]rune to []string for error messages
+func runeSliceToStringSlice(runes [][]rune) []string {
+ result := make([]string, len(runes))
+ for i, r := range runes {
+ result[i] = string(r)
+ }
+ return result
+}
diff --git a/internal/repl/history.go b/internal/repl/history.go
index 5f50dee..a425207 100644
--- a/internal/repl/history.go
+++ b/internal/repl/history.go
@@ -14,11 +14,13 @@ import (
// It provides methods to load, save, and manage command history with a maximum entry limit.
type HistoryManager struct {
historyFile string
+ baseDir string // override for testing; empty means use os.UserHomeDir()
maxEntries int
}
// NewHistoryManager creates a new history manager with the given file name.
// The history manager will store up to maxEntries (default: 1000) in the history file.
+// The file is stored in the user's home directory.
//
// historyFile: the filename to use for history (without path)
// Returns a new HistoryManager instance
@@ -29,16 +31,31 @@ func NewHistoryManager(historyFile string) *HistoryManager {
}
}
+// WithBaseDir sets a custom base directory for the history file path.
+// When baseDir is empty, Path() uses os.UserHomeDir().
+// This is primarily useful for testing.
+//
+// baseDir: the directory to use instead of the home directory
+// Returns the same HistoryManager for chaining
+func (h *HistoryManager) WithBaseDir(baseDir string) *HistoryManager {
+ h.baseDir = baseDir
+ return h
+}
+
// Path returns the absolute path to the history file.
-// The history file is stored in the user's home directory.
+// The history file is stored in the user's home directory, or in baseDir if set.
//
-// Returns the full path to the history file, or empty string if the home directory cannot be determined
+// Returns the full path to the history file, or empty string if the directory cannot be determined
func (h *HistoryManager) Path() string {
- home, err := os.UserHomeDir()
- if err != nil {
- return ""
+ base := h.baseDir
+ if base == "" {
+ var err error
+ base, err = os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
}
- return filepath.Join(home, h.historyFile)
+ return filepath.Join(base, h.historyFile)
}
// Load reads history from the history file.
diff --git a/internal/repl/history_test.go b/internal/repl/history_test.go
new file mode 100644
index 0000000..928c279
--- /dev/null
+++ b/internal/repl/history_test.go
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package repl
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestHistoryManagerNew(t *testing.T) {
+ hm := NewHistoryManager(".test_history")
+ if hm == nil {
+ t.Fatal("NewHistoryManager returned nil")
+ }
+ if hm.historyFile != ".test_history" {
+ t.Errorf("historyFile = %q, want %q", hm.historyFile, ".test_history")
+ }
+ if hm.maxEntries != 1000 {
+ t.Errorf("maxEntries = %d, want 1000", hm.maxEntries)
+ }
+}
+
+func TestHistoryManagerPath(t *testing.T) {
+ hm := NewHistoryManager(".test_history")
+ path := hm.Path()
+ if path == "" {
+ t.Fatal("Path returned empty string")
+ }
+ if !strings.HasSuffix(path, ".test_history") {
+ t.Errorf("Path should end with .test_history, got %q", path)
+ }
+}
+
+func TestHistoryManagerPathWithBaseDir(t *testing.T) {
+ hm := NewHistoryManager(".test_history").WithBaseDir("/tmp")
+ path := hm.Path()
+ if path != "/tmp/.test_history" {
+ t.Errorf("Path = %q, want /tmp/.test_history", path)
+ }
+}
+
+func TestHistoryManagerSaveAndLoad(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".test_history").WithBaseDir(tmpDir)
+
+ entries := []string{"20% of 150", "rpn 3 4 +", "help"}
+
+ if err := hm.Save(entries); err != nil {
+ t.Fatalf("Save returned error: %v", err)
+ }
+
+ loaded := hm.Load()
+ if loaded == nil {
+ t.Fatal("Load returned nil")
+ }
+ if len(loaded) != len(entries) {
+ t.Fatalf("Load returned %d entries, want %d", len(loaded), len(entries))
+ }
+ for i, want := range entries {
+ if loaded[i] != want {
+ t.Errorf("entry %d = %q, want %q", i, loaded[i], want)
+ }
+ }
+}
+
+func TestHistoryManagerLoadNonExistent(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".nonexistent").WithBaseDir(tmpDir)
+ history := hm.Load()
+ if history != nil {
+ t.Errorf("Load from non-existent file returned %d entries, want nil", len(history))
+ }
+}
+
+func TestHistoryManagerLoadEmptyFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".empty_history").WithBaseDir(tmpDir)
+
+ // Create empty file
+ if err := os.WriteFile(hm.Path(), []byte{}, 0644); err != nil {
+ t.Fatalf("failed to create empty file: %v", err)
+ }
+
+ history := hm.Load()
+ // Empty file should return empty slice (not nil) since scanner.Scan returns false immediately
+ if len(history) != 0 {
+ t.Errorf("Load from empty file returned %d entries, want 0", len(history))
+ }
+}
+
+func TestHistoryManagerSaveRespectsMaxEntries(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".test_max").WithBaseDir(tmpDir)
+
+ // Create 1500 entries (more than default max of 1000)
+ largeEntries := make([]string, 1500)
+ for i := range largeEntries {
+ largeEntries[i] = strings.Repeat("x", 10)
+ }
+
+ if err := hm.Save(largeEntries); err != nil {
+ t.Fatalf("Save returned error: %v", err)
+ }
+
+ loaded := hm.Load()
+ if loaded == nil {
+ t.Fatal("Load returned nil after Save")
+ }
+ if len(loaded) != 1000 {
+ t.Errorf("after Save(1500 entries), Load returned %d, want 1000 (maxEntries)", len(loaded))
+ }
+ // Verify it keeps the LAST 1000 entries, not the first 1000
+ if loaded[0] != largeEntries[500] {
+ t.Error("truncated entries should keep the last 1000, not the first 1000")
+ }
+ if loaded[999] != largeEntries[1499] {
+ t.Error("last loaded entry should be last saved entry")
+ }
+}
+
+func TestHistoryManagerSaveCreatesFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".gt_test_history").WithBaseDir(tmpDir)
+
+ entries := []string{"entry1", "entry2", "entry3"}
+ if err := hm.Save(entries); err != nil {
+ t.Fatalf("Save returned error: %v", err)
+ }
+
+ // Verify file was created
+ data, err := os.ReadFile(hm.Path())
+ if err != nil {
+ t.Fatalf("failed to read created file: %v", err)
+ }
+
+ content := string(data)
+ for _, entry := range entries {
+ if !strings.Contains(content, entry) {
+ t.Errorf("file should contain %q, got %q", entry, content)
+ }
+ }
+}
+
+func TestHistoryManagerSaveWithEmptySlice(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".empty_save").WithBaseDir(tmpDir)
+
+ if err := hm.Save(nil); err != nil {
+ t.Fatalf("Save(nil) returned error: %v", err)
+ }
+
+ data, err := os.ReadFile(hm.Path())
+ if err != nil {
+ t.Fatalf("failed to read file: %v", err)
+ }
+ if string(data) != "" {
+ t.Errorf("Save(nil) should produce empty file, got %q", string(data))
+ }
+}
+
+func TestHistoryManagerSaveWithSpecialCharacters(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".special_chars").WithBaseDir(tmpDir)
+
+ entries := []string{
+ "help",
+ `"quoted entry"`,
+ "entry with spaces",
+ "rpn 3 4 +",
+ }
+
+ if err := hm.Save(entries); err != nil {
+ t.Fatalf("Save returned error: %v", err)
+ }
+
+ loaded := hm.Load()
+ if len(loaded) != len(entries) {
+ t.Fatalf("Load returned %d entries, want %d", len(loaded), len(entries))
+ }
+ for i, want := range entries {
+ if loaded[i] != want {
+ t.Errorf("entry %d = %q, want %q", i, loaded[i], want)
+ }
+ }
+}
+
+func TestHistoryManagerSaveOverwritesExisting(t *testing.T) {
+ tmpDir := t.TempDir()
+ hm := NewHistoryManager(".overwrite_test").WithBaseDir(tmpDir)
+
+ // Save initial entries
+ if err := hm.Save([]string{"old1", "old2"}); err != nil {
+ t.Fatalf("first Save: %v", err)
+ }
+
+ // Save new entries
+ if err := hm.Save([]string{"new1"}); err != nil {
+ t.Fatalf("second Save: %v", err)
+ }
+
+ loaded := hm.Load()
+ if len(loaded) != 1 {
+ t.Fatalf("Load returned %d entries, want 1", len(loaded))
+ }
+ if loaded[0] != "new1" {
+ t.Errorf("Load returned %q, want new1 (file should be overwritten)", loaded[0])
+ }
+}
+
+func TestHistoryManagerWithEmptyBaseDir(t *testing.T) {
+ // When baseDir is empty, Path() falls back to os.UserHomeDir()
+ hm := NewHistoryManager(".test").WithBaseDir("")
+ path := hm.Path()
+ if path == "" {
+ t.Fatal("Path should fall back to os.UserHomeDir when baseDir is empty")
+ }
+ if !strings.HasSuffix(path, ".test") {
+ t.Errorf("Path = %q, should end with .test", path)
+ }
+}
diff --git a/internal/repl/signal_test.go b/internal/repl/signal_test.go
new file mode 100644
index 0000000..7d5e72e
--- /dev/null
+++ b/internal/repl/signal_test.go
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package repl
+
+import (
+ "os"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func TestNewSignalHandler(t *testing.T) {
+ h := NewSignalHandler()
+ if h == nil {
+ t.Fatal("NewSignalHandler returned nil")
+ }
+}
+
+func TestSignalHandlerStop(t *testing.T) {
+ h := NewSignalHandler()
+ // Stop should not panic on a handler that hasn't started
+ h.Stop()
+}
+
+func TestSignalHandlerStartExecutesCallback(t *testing.T) {
+ h := NewSignalHandler()
+ defer h.Stop()
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+
+ h.Start(func() {
+ wg.Done()
+ })
+
+ // Send SIGINT to ourselves to trigger the handler
+ pid := os.Getpid()
+ if err := syscall.Kill(pid, syscall.SIGINT); err != nil {
+ t.Skipf("cannot send signal: %v", err)
+ }
+
+ // Wait for callback with timeout
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // Callback was executed
+ case <-time.After(2 * time.Second):
+ t.Error("callback was not executed within timeout")
+ }
+}
+
+func TestSignalHandlerStartCallbackRunsInGoroutine(t *testing.T) {
+ h := NewSignalHandler()
+ defer h.Stop()
+
+ started := make(chan struct{})
+ finished := make(chan struct{})
+
+ h.Start(func() {
+ close(started)
+ // Simulate some work
+ time.Sleep(100 * time.Millisecond)
+ close(finished)
+ })
+
+ // Send SIGINT to trigger
+ pid := os.Getpid()
+ if err := syscall.Kill(pid, syscall.SIGINT); err != nil {
+ t.Skipf("cannot send signal: %v", err)
+ }
+
+ // Start() should return immediately (goroutine)
+ select {
+ case <-started:
+ // Good, callback started
+ case <-time.After(2 * time.Second):
+ t.Error("callback goroutine did not start")
+ }
+
+ // Wait for it to finish
+ select {
+ case <-finished:
+ // Good
+ case <-time.After(2 * time.Second):
+ t.Error("callback goroutine did not finish")
+ }
+}
+
+func TestSignalHandlerSingleShot(t *testing.T) {
+ h := NewSignalHandler()
+
+ var callbackCount int
+ h.Start(func() {
+ callbackCount++
+ })
+
+ // First signal should trigger callback
+ pid := os.Getpid()
+ if err := syscall.Kill(pid, syscall.SIGINT); err != nil {
+ t.Skipf("cannot send signal: %v", err)
+ }
+ time.Sleep(200 * time.Millisecond)
+
+ // The handler is single-shot: after consuming one signal the goroutine exits.
+ // Stop() unregisters the signal channel.
+ h.Stop()
+
+ if callbackCount != 1 {
+ t.Errorf("expected callback count 1, got %d", callbackCount)
+ }
+}
diff --git a/internal/repl/tty_test.go b/internal/repl/tty_test.go
new file mode 100644
index 0000000..3f04860
--- /dev/null
+++ b/internal/repl/tty_test.go
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package repl
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTTYCheckerEnsureTTYNotATerminal(t *testing.T) {
+ // In test context, stdin is never a terminal
+ checker := &TTYChecker{}
+ err := checker.EnsureTTY()
+ if err == nil {
+ t.Error("EnsureTTY should return an error when stdin is not a TTY")
+ }
+}
+
+func TestTTYCheckerEnsureTTYErrorMessage(t *testing.T) {
+ checker := &TTYChecker{}
+ err := checker.EnsureTTY()
+ if err == nil {
+ t.Skip("stdin appears to be a TTY; skipping error message check")
+ }
+ if !strings.Contains(err.Error(), "TTY") {
+ t.Errorf("error message should mention TTY, got: %q", err.Error())
+ }
+}
+
+func TestTTYCheckerIsTTYReturnsFalseInTests(t *testing.T) {
+ // In test context, stdin is not a terminal
+ checker := &TTYChecker{}
+ if checker.IsTTY() {
+ t.Skip("stdin appears to be a TTY (e.g., interactive terminal); skipping")
+ }
+ // Good — IsTTY correctly returned false
+}
+
+func TestTTYCheckerIsTTYConsistentWithEnsureTTY(t *testing.T) {
+ checker := &TTYChecker{}
+ isTTY := checker.IsTTY()
+ err := checker.EnsureTTY()
+
+ if isTTY && err != nil {
+ t.Error("IsTTY returned true but EnsureTTY returned an error")
+ }
+ if !isTTY && err == nil {
+ t.Error("IsTTY returned false but EnsureTTY returned nil")
+ }
+}
+
+func TestTTYCheckerMultipleCalls(t *testing.T) {
+ checker := &TTYChecker{}
+ // Multiple calls should be consistent
+ result1 := checker.IsTTY()
+ result2 := checker.IsTTY()
+ if result1 != result2 {
+ t.Errorf("IsTTY returned inconsistent results: %v, %v", result1, result2)
+ }
+
+ err1 := checker.EnsureTTY()
+ err2 := checker.EnsureTTY()
+ // Both errors should be the same type (both nil or both non-nil)
+ bothNil := err1 == nil && err2 == nil
+ bothNonNil := err1 != nil && err2 != nil
+ if !bothNil && !bothNonNil {
+ t.Errorf("EnsureTTY returned inconsistent results: %v, %v", err1, err2)
+ }
+}