diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-23 20:54:36 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-23 20:54:36 +0300 |
| commit | 174b313c5ea475ff0baadf409f226d3e7b65738a (patch) | |
| tree | 50e8503294a02faec07366e5fbef8aba2c4edbe7 /internal/repl | |
| parent | 54de2315861cdac384183623e1b466f83f397dfc (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.go | 290 | ||||
| -rw-r--r-- | internal/repl/history.go | 29 | ||||
| -rw-r--r-- | internal/repl/history_test.go | 222 | ||||
| -rw-r--r-- | internal/repl/signal_test.go | 118 | ||||
| -rw-r--r-- | internal/repl/tty_test.go | 70 |
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) + } +} |
