Gim/internal/core/command_test.go
Hayden Hargreaves 5ff473d0d9
All checks were successful
Run Test Suite / test (push) Successful in 42s
feat: implementing scrolling cmd output window: tested
2026-03-19 14:32:21 -07:00

589 lines
14 KiB
Go

package core
import "testing"
// TestCommandOutputHeight tests the Height() method for various configurations
func TestCommandOutputHeight(t *testing.T) {
t.Run("inline mode returns 1", func(t *testing.T) {
co := &CommandOutput{
Title: "Test Title",
Lines: []string{"line1", "line2", "line3"},
Inline: true,
}
if got := co.Height(); got != 1 {
t.Errorf("Height() = %d, want 1 for inline mode", got)
}
})
t.Run("empty output with no title", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{},
Inline: false,
}
// 0 lines + 0 title + 2 padding = 2
want := 2
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("one line with title", func(t *testing.T) {
co := &CommandOutput{
Title: "Test",
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 1 title + 2 padding = 4
want := 4
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("one line without title", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 0 title + 2 padding = 3
want := 3
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("multiple lines with title", func(t *testing.T) {
co := &CommandOutput{
Title: "Output",
Lines: []string{"line1", "line2", "line3", "line4", "line5"},
Inline: false,
}
// 5 lines + 1 title + 2 padding = 8
want := 8
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("whitespace-only title counts as no title", func(t *testing.T) {
co := &CommandOutput{
Title: " ",
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 0 title (whitespace) + 2 padding = 3
want := 3
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("empty string title counts as no title", func(t *testing.T) {
co := &CommandOutput{
Title: "",
Lines: []string{"line1", "line2"},
Inline: false,
}
// 2 lines + 0 title + 2 padding = 4
want := 4
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
}
// TestCommandOutputIsActive tests the IsActive() method
func TestCommandOutputIsActive(t *testing.T) {
t.Run("active with lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1"},
}
if !co.IsActive() {
t.Error("IsActive() = false, want true when lines present")
}
})
t.Run("inactive with no lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{},
}
if co.IsActive() {
t.Error("IsActive() = true, want false when no lines")
}
})
t.Run("inactive with nil lines", func(t *testing.T) {
co := &CommandOutput{
Lines: nil,
}
if co.IsActive() {
t.Error("IsActive() = true, want false when lines is nil")
}
})
t.Run("active with multiple lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1", "line2", "line3"},
}
if !co.IsActive() {
t.Error("IsActive() = false, want true when multiple lines present")
}
})
}
// TestMaxOutputWindowHeight tests the maxOutputWindowHeight helper function
func TestMaxOutputWindowHeight(t *testing.T) {
tests := []struct {
name string
termHeight int
want int
}{
{"100 height terminal", 100, 75},
{"80 height terminal", 80, 60},
{"40 height terminal", 40, 30},
{"24 height terminal", 24, 18},
{"20 height terminal", 20, 15},
{"10 height terminal", 10, 7},
{"1 height terminal", 1, 0},
{"0 height terminal", 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := maxOutputWindowHeight(tt.termHeight)
if got != tt.want {
t.Errorf("maxOutputWindowHeight(%d) = %d, want %d", tt.termHeight, got, tt.want)
}
})
}
}
// TestCommandOutputViewport tests the Viewport() method
func TestCommandOutputViewport(t *testing.T) {
t.Run("viewport at start with small content", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1", "line2", "line3"},
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75, viewport shows all 3 lines
viewport := co.Viewport(100)
if len(viewport) != 3 {
t.Errorf("Viewport() returned %d lines, want 3", len(viewport))
}
want := []string{"line1", "line2", "line3"}
for i, line := range viewport {
if line != want[i] {
t.Errorf("Viewport()[%d] = %q, want %q", i, line, want[i])
}
}
})
t.Run("viewport at start with large content", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75
viewport := co.Viewport(100)
if len(viewport) != 75 {
t.Errorf("Viewport() returned %d lines, want 75", len(viewport))
}
// Should start with first line
if viewport[0] != "A" {
t.Errorf("Viewport()[0] = %q, want 'A'", viewport[0])
}
})
t.Run("viewport scrolled down", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"},
ScrollOffset: 3,
}
// Terminal height 20 → max window = 15
viewport := co.Viewport(20)
// Should start at offset 3 (line "D")
if viewport[0] != "D" {
t.Errorf("Viewport()[0] = %q, want 'D' (offset 3)", viewport[0])
}
})
t.Run("viewport with different terminal heights", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('0' + (i % 10)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Small terminal (40 lines) → max window = 30
viewport := co.Viewport(40)
if len(viewport) != 30 {
t.Errorf("Viewport(40) returned %d lines, want 30", len(viewport))
}
// Large terminal (200 lines) → max window = 150, but only 100 lines available
viewport = co.Viewport(200)
if len(viewport) != 100 {
t.Errorf("Viewport(200) returned %d lines, want 100 (all available)", len(viewport))
}
})
t.Run("viewport at maximum scroll", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 2, // Showing lines from index 2 onwards
}
// Terminal height 20 → max window = 15 (but only 3 lines available from offset 2)
viewport := co.Viewport(20)
want := []string{"C", "D", "E"}
if len(viewport) != len(want) {
t.Errorf("Viewport() returned %d lines, want %d", len(viewport), len(want))
}
for i, line := range viewport {
if line != want[i] {
t.Errorf("Viewport()[%d] = %q, want %q", i, line, want[i])
}
}
})
}
// TestCommandOutputScrollDown tests the ScrollDown() method
func TestCommandOutputScrollDown(t *testing.T) {
t.Run("scroll down with space available", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75
// Can scroll since 100 lines > 75 viewport
co.ScrollDown(100)
if co.ScrollOffset != 1 {
t.Errorf("ScrollOffset = %d after ScrollDown, want 1", co.ScrollOffset)
}
})
t.Run("scroll down multiple times", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Scroll down 5 times
for i := 0; i < 5; i++ {
co.ScrollDown(100)
}
if co.ScrollOffset != 5 {
t.Errorf("ScrollOffset = %d after 5 ScrollDown calls, want 5", co.ScrollOffset)
}
})
t.Run("cannot scroll past end of content", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 0,
}
// Terminal height 20 → max window = 15
// 5 lines fit entirely in viewport, should not scroll
co.ScrollDown(20)
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d, want 0 (should not scroll when content fits)", co.ScrollOffset)
}
})
t.Run("scroll down to maximum", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"},
ScrollOffset: 0,
}
// Terminal height 16 → max window = 12
// 10 lines total, can scroll down until offset + 12 >= 10
// Max offset = 10 - 12 = -2, but we can't go negative
// Actually: can scroll while (offset + 12) < 10
// So max offset before stopping = 10 - 12 = can't scroll at all? Let me recalculate
// offset=0: show lines 0-11 (but only 10 exist) → shows all 10
// Can't scroll since viewport > content
// Let's use different numbers: 20 lines, window of 12
lines := make([]string, 20)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co.Lines = lines
// Max scroll: offset + 12 < 20 → offset < 8 → max offset = 7
// But the function allows offset until (offset + 12) < 20
// So when offset = 8, offset + 12 = 20, not < 20, stops
for i := 0; i < 20; i++ {
co.ScrollDown(16)
}
// Maximum offset should be 8 (showing lines 8-19, which is 12 lines)
want := 8
if co.ScrollOffset != want {
t.Errorf("ScrollOffset = %d after scrolling to max, want %d", co.ScrollOffset, want)
}
})
t.Run("scroll down at maximum has no effect", func(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 8, // Already at max for height 16 (window 12)
}
co.ScrollDown(16)
if co.ScrollOffset != 8 {
t.Errorf("ScrollOffset = %d, want 8 (should not scroll past end)", co.ScrollOffset)
}
})
t.Run("scroll with different terminal heights", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Small terminal (height 40 → window 30)
co.ScrollDown(40)
if co.ScrollOffset != 1 {
t.Errorf("ScrollOffset = %d for height 40, want 1", co.ScrollOffset)
}
// Reset
co.ScrollOffset = 0
// Large terminal (height 200 → window 150)
co.ScrollDown(200)
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d for height 200, want 0 (content fits entirely)", co.ScrollOffset)
}
})
}
// TestCommandOutputScrollUp tests the ScrollUp() method
func TestCommandOutputScrollUp(t *testing.T) {
t.Run("scroll up from offset", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 3,
}
co.ScrollUp()
if co.ScrollOffset != 2 {
t.Errorf("ScrollOffset = %d after ScrollUp, want 2", co.ScrollOffset)
}
})
t.Run("scroll up multiple times", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 5,
}
for i := 0; i < 3; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 2 {
t.Errorf("ScrollOffset = %d after 3 ScrollUp calls, want 2", co.ScrollOffset)
}
})
t.Run("cannot scroll up past zero", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 0,
}
co.ScrollUp()
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d, want 0 (should not go negative)", co.ScrollOffset)
}
})
t.Run("scroll up to zero", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 3,
}
// Scroll up 3 times to reach 0
for i := 0; i < 3; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling to top, want 0", co.ScrollOffset)
}
// Try scrolling up one more time
co.ScrollUp()
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling past top, want 0", co.ScrollOffset)
}
})
t.Run("scroll up from large offset", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 50,
}
co.ScrollUp()
if co.ScrollOffset != 49 {
t.Errorf("ScrollOffset = %d after ScrollUp, want 49", co.ScrollOffset)
}
})
}
// TestCommandOutputScrollingIntegration tests combined scrolling behavior
func TestCommandOutputScrollingIntegration(t *testing.T) {
t.Run("scroll down and up", func(t *testing.T) {
lines := make([]string, 50)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
termHeight := 40 // max window = 30
// Scroll down 5 times
for i := 0; i < 5; i++ {
co.ScrollDown(termHeight)
}
if co.ScrollOffset != 5 {
t.Errorf("ScrollOffset = %d after 5 down, want 5", co.ScrollOffset)
}
// Scroll up 2 times
for i := 0; i < 2; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 3 {
t.Errorf("ScrollOffset = %d after 2 up, want 3", co.ScrollOffset)
}
// Scroll back to top
for i := 0; i < 10; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling to top, want 0", co.ScrollOffset)
}
})
t.Run("scroll to bottom and back to top", func(t *testing.T) {
lines := make([]string, 30)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
termHeight := 28 // max window = 21
// Scroll to bottom (max offset = 30 - 21 = 9)
for i := 0; i < 20; i++ {
co.ScrollDown(termHeight)
}
want := 9
if co.ScrollOffset != want {
t.Errorf("ScrollOffset = %d after scrolling to bottom, want %d", co.ScrollOffset, want)
}
// Verify viewport shows last lines
viewport := co.Viewport(termHeight)
if len(viewport) != 21 {
t.Errorf("Viewport length = %d, want 21", len(viewport))
}
if viewport[len(viewport)-1] != string(rune('A'+29)) {
t.Errorf("Last viewport line = %q, want %q", viewport[len(viewport)-1], string(rune('A'+29)))
}
// Scroll back to top
for i := 0; i < 20; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling back to top, want 0", co.ScrollOffset)
}
})
}