diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2bc72cb..906d823 100755 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -133,7 +133,9 @@ func TestBindFlags(t *testing.T) { flags.Int("line-width", 6, "") flags.String("prompt", "watchr> ", "") flags.String("refresh", "0", "") + flags.Bool("refresh-from-start", false, "") flags.Bool("no-line-numbers", false, "") + flags.Bool("interactive", false, "") // Parse with custom values err := flags.Parse([]string{"--shell=bash", "--preview-size=50%", "--line-width=8"}) @@ -239,7 +241,9 @@ preview-size: "60%" flags.Int("line-width", 6, "") flags.String("prompt", "watchr> ", "") flags.String("refresh", "0", "") + flags.Bool("refresh-from-start", false, "") flags.Bool("no-line-numbers", false, "") + flags.Bool("interactive", false, "") // Override shell via flag err := flags.Parse([]string{"--shell=bash"}) @@ -556,6 +560,107 @@ func TestGetDuration(t *testing.T) { } } +func TestRefreshFromStartDefault(t *testing.T) { + _, cleanup := isolateConfig(t) + defer cleanup() + + Init() + + // Default should be false + if got := GetBool(KeyRefreshFromStart); got != false { + t.Errorf("expected default refresh-from-start false, got %v", got) + } +} + +func TestRefreshFromStartFromConfigFile(t *testing.T) { + tmpDir, cleanup := isolateConfig(t) + defer cleanup() + + // Create config file with refresh-from-start: true + configPath := filepath.Join(tmpDir, "watchr.yaml") + configContent := `refresh-from-start: true +` + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + Init() + + if got := GetBool(KeyRefreshFromStart); got != true { + t.Errorf("expected refresh-from-start true from config file, got %v", got) + } +} + +func TestRefreshFromStartFromFlag(t *testing.T) { + _, cleanup := isolateConfig(t) + defer cleanup() + + Init() + + // Create flags and parse + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("shell", "sh", "") + flags.String("preview-size", "40%", "") + flags.String("preview-position", "bottom", "") + flags.Int("line-width", 6, "") + flags.String("prompt", "watchr> ", "") + flags.String("refresh", "0", "") + flags.Bool("refresh-from-start", false, "") + flags.Bool("no-line-numbers", false, "") + flags.Bool("interactive", false, "") + + // Parse with refresh-from-start=true + err := flags.Parse([]string{"--refresh-from-start=true"}) + if err != nil { + t.Fatalf("failed to parse flags: %v", err) + } + + BindFlags(flags) + + if got := GetBool(KeyRefreshFromStart); got != true { + t.Errorf("expected refresh-from-start true from flag, got %v", got) + } +} + +func TestRefreshFromStartFlagOverridesConfig(t *testing.T) { + tmpDir, cleanup := isolateConfig(t) + defer cleanup() + + // Create config file with refresh-from-start: true + configPath := filepath.Join(tmpDir, "watchr.yaml") + configContent := `refresh-from-start: true +` + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + Init() + + // Create flags and parse with refresh-from-start=false + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("shell", "sh", "") + flags.String("preview-size", "40%", "") + flags.String("preview-position", "bottom", "") + flags.Int("line-width", 6, "") + flags.String("prompt", "watchr> ", "") + flags.String("refresh", "0", "") + flags.Bool("refresh-from-start", false, "") + flags.Bool("no-line-numbers", false, "") + flags.Bool("interactive", false, "") + + err := flags.Parse([]string{"--refresh-from-start=false"}) + if err != nil { + t.Fatalf("failed to parse flags: %v", err) + } + + BindFlags(flags) + + // Flag should override config + if got := GetBool(KeyRefreshFromStart); got != false { + t.Errorf("expected refresh-from-start false (flag override), got %v", got) + } +} + func TestRefreshDurationFromConfigFile(t *testing.T) { tmpDir, cleanup := isolateConfig(t) defer cleanup() diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index c1292e3..e4e0526 100755 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -378,3 +378,147 @@ func TestSanitizeLine(t *testing.T) { }) } } + +func TestRunStreaming(t *testing.T) { + r := NewRunner("sh", "echo 'line1'; echo 'line2'; echo 'line3'") + ctx := context.Background() + + result := r.RunStreaming(ctx, nil) + + // Wait for completion + for !result.IsDone() { + time.Sleep(10 * time.Millisecond) + } + + lines := result.GetLines() + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + + if lines[0].Content != "line1" { + t.Errorf("expected first line 'line1', got %q", lines[0].Content) + } + + if result.GetCurrentLineCount() != 3 { + t.Errorf("expected CurrentLineCount 3, got %d", result.GetCurrentLineCount()) + } +} + +func TestRunStreamingWithPreviousLines(t *testing.T) { + // Previous lines that should be overwritten + prevLines := []Line{ + {Number: 1, Content: "old1"}, + {Number: 2, Content: "old2"}, + {Number: 3, Content: "old3"}, + {Number: 4, Content: "old4"}, + {Number: 5, Content: "old5"}, + } + + r := NewRunner("sh", "echo 'new1'; echo 'new2'; echo 'new3'") + ctx := context.Background() + + result := r.RunStreaming(ctx, prevLines) + + // Verify PrevLineCount is set + if result.PrevLineCount != 5 { + t.Errorf("expected PrevLineCount 5, got %d", result.PrevLineCount) + } + + // Wait for completion + for !result.IsDone() { + time.Sleep(10 * time.Millisecond) + } + + lines := result.GetLines() + + // Should still have 5 lines (3 new + 2 old remaining) + if len(lines) != 5 { + t.Fatalf("expected 5 lines (in-place update), got %d", len(lines)) + } + + // First 3 lines should be overwritten + if lines[0].Content != "new1" { + t.Errorf("expected line 0 'new1', got %q", lines[0].Content) + } + if lines[1].Content != "new2" { + t.Errorf("expected line 1 'new2', got %q", lines[1].Content) + } + if lines[2].Content != "new3" { + t.Errorf("expected line 2 'new3', got %q", lines[2].Content) + } + + // Remaining lines should be old (not touched) + if lines[3].Content != "old4" { + t.Errorf("expected line 3 'old4', got %q", lines[3].Content) + } + if lines[4].Content != "old5" { + t.Errorf("expected line 4 'old5', got %q", lines[4].Content) + } + + // CurrentLineCount should be 3 (only new lines written) + if result.GetCurrentLineCount() != 3 { + t.Errorf("expected CurrentLineCount 3, got %d", result.GetCurrentLineCount()) + } +} + +func TestRunStreamingMoreLinesThanPrevious(t *testing.T) { + // Previous lines (fewer than new output) + prevLines := []Line{ + {Number: 1, Content: "old1"}, + {Number: 2, Content: "old2"}, + } + + r := NewRunner("sh", "echo 'new1'; echo 'new2'; echo 'new3'; echo 'new4'") + ctx := context.Background() + + result := r.RunStreaming(ctx, prevLines) + + // Wait for completion + for !result.IsDone() { + time.Sleep(10 * time.Millisecond) + } + + lines := result.GetLines() + + // Should have 4 lines (2 overwritten + 2 appended) + if len(lines) != 4 { + t.Fatalf("expected 4 lines, got %d", len(lines)) + } + + // All lines should be new + for i, expected := range []string{"new1", "new2", "new3", "new4"} { + if lines[i].Content != expected { + t.Errorf("expected line %d %q, got %q", i, expected, lines[i].Content) + } + } + + if result.GetCurrentLineCount() != 4 { + t.Errorf("expected CurrentLineCount 4, got %d", result.GetCurrentLineCount()) + } +} + +func TestStreamingResultThreadSafety(t *testing.T) { + r := NewRunner("sh", "for i in $(seq 1 100); do echo line$i; done") + ctx := context.Background() + + result := r.RunStreaming(ctx, nil) + + // Concurrently read while streaming + done := make(chan bool) + go func() { + for !result.IsDone() { + _ = result.GetLines() + _ = result.LineCount() + _ = result.GetCurrentLineCount() + time.Sleep(5 * time.Millisecond) + } + done <- true + }() + + <-done + + // Should complete without race conditions + if result.LineCount() != 100 { + t.Errorf("expected 100 lines, got %d", result.LineCount()) + } +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 8180c04..cfcaa0d 100755 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -284,3 +284,132 @@ func TestVisibleLines(t *testing.T) { t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible) } } + +func TestUpdateFilteredPreservesOffset(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + m.height = 20 // Enough for visibleLines to return > 0 + + // Add many test lines + for i := 1; i <= 100; i++ { + m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) + } + + // Set initial state with offset + m.filter = "" + m.updateFiltered() + m.offset = 50 + m.cursor = 55 + + // Simulate streaming update - add more lines without changing filter + m.lines = append(m.lines, runner.Line{Number: 101, Content: "new line"}) + m.updateFiltered() + + // Offset should be preserved (or clamped if necessary) + if m.offset < 50 { + t.Errorf("expected offset to be preserved (>= 50), got %d", m.offset) + } + + // Cursor should be preserved + if m.cursor != 55 { + t.Errorf("expected cursor to be preserved at 55, got %d", m.cursor) + } +} + +func TestUpdateFilteredClampsOffsetWhenNeeded(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + m.height = 20 + + // Add test lines + for i := 1; i <= 100; i++ { + m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) + } + + m.filter = "" + m.updateFiltered() + m.offset = 90 + m.cursor = 95 + + // Now filter to fewer lines + m.filter = "xyz" // No matches + m.updateFiltered() + + // Offset should be clamped to valid range + if m.offset != 0 { + t.Errorf("expected offset to be clamped to 0, got %d", m.offset) + } + + // Cursor should be clamped + if m.cursor != 0 { + t.Errorf("expected cursor to be clamped to 0, got %d", m.cursor) + } +} + +func TestConfigRefreshFromStart(t *testing.T) { + // Test with RefreshFromStart false (default) + cfg := Config{ + Command: "echo test", + Shell: "sh", + RefreshInterval: 5 * time.Second, + RefreshFromStart: false, + } + + if cfg.RefreshFromStart { + t.Error("expected RefreshFromStart to be false by default") + } + + // Test with RefreshFromStart true + cfg.RefreshFromStart = true + if !cfg.RefreshFromStart { + t.Error("expected RefreshFromStart to be true after setting") + } +} + +func TestModelUserScrolled(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + + // Initially should be false + if m.userScrolled { + t.Error("expected userScrolled to be false initially") + } + + // After setting, should be true + m.userScrolled = true + if !m.userScrolled { + t.Error("expected userScrolled to be true after setting") + } +} + +func TestModelRefreshGeneration(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + + // Initially should be 0 + if m.refreshGeneration != 0 { + t.Errorf("expected refreshGeneration to be 0 initially, got %d", m.refreshGeneration) + } + + // After incrementing + m.refreshGeneration++ + if m.refreshGeneration != 1 { + t.Errorf("expected refreshGeneration to be 1 after increment, got %d", m.refreshGeneration) + } +}