package tui import ( "testing" tea "charm.land/bubbletea/v2" "ai-agent/internal/agent" "ai-agent/internal/command" ) func TestScrollAnchor_Initialization(t *testing.T) { m := newTestModel(t) updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = updated.(*Model) if !m.ready { t.Fatal("viewport should be ready after WindowSizeMsg") } if !m.anchorActive { t.Error("anchorActive should be true after initialization") } if m.scrollAnchor != 0 { t.Errorf("scrollAnchor should be 0, got %d", m.scrollAnchor) } if m.lastContentHeight != 0 { t.Errorf("lastContentHeight should be 0, got %d", m.lastContentHeight) } } func TestScrollAnchor_MouseWheelUp(t *testing.T) { m := newTestModel(t) m.anchorActive = true m.userScrolledUp = false var longContent string for i := 0; i < 100; i++ { longContent += "line " + string(rune(i)) + "\n" } m.viewport.SetContent(longContent) m.viewport.GotoBottom() if !m.viewport.AtBottom() { t.Fatal("viewport should be at bottom before scroll") } updated, _ := m.Update(tea.MouseWheelMsg{X: 0, Y: 0, Button: tea.MouseWheelUp}) m = updated.(*Model) if m.anchorActive { t.Error("anchorActive should be false after scrolling up") } if !m.userScrolledUp { t.Error("userScrolledUp should be true after scrolling up") } if m.scrollAnchor <= 0 { t.Error("scrollAnchor should be positive after scrolling up") } } func TestScrollAnchor_MouseWheelDown(t *testing.T) { m := newTestModel(t) m.anchorActive = false m.userScrolledUp = true m.scrollAnchor = 10 m.viewport.SetContent("short content") updated, _ := m.Update(tea.MouseWheelMsg{X: 0, Y: 0, Button: tea.MouseWheelDown}) m = updated.(*Model) if !m.anchorActive { t.Error("anchorActive should be true when at bottom") } } func TestScrollAnchor_StreamTextMsg(t *testing.T) { m := newTestModel(t) m.state = StateStreaming m.entries = []ChatEntry{ {Kind: "assistant", Content: "Initial response"}, } m.viewport.SetContent(m.renderEntries()) m.anchorActive = true updated, _ := m.Update(StreamTextMsg{Text: "more"}) m = updated.(*Model) if !m.viewport.AtBottom() { t.Error("viewport should be at bottom when anchor is active") } m.anchorActive = false m.viewport.GotoTop() updated, _ = m.Update(StreamTextMsg{Text: "even more"}) m = updated.(*Model) if m.viewport.AtBottom() { t.Log("Note: viewport scrolled to bottom even with anchor inactive") } } func TestScrollAnchor_AgentDoneMsg(t *testing.T) { m := newTestModel(t) m.state = StateStreaming m.anchorActive = false m.userScrolledUp = true m.scrollAnchor = 10 updated, _ := m.Update(AgentDoneMsg{}) m = updated.(*Model) if m.state != StateIdle { t.Errorf("state should be StateIdle, got %d", m.state) } if !m.anchorActive { t.Error("anchorActive should be reset to true after AgentDoneMsg") } if m.scrollAnchor != 0 { t.Errorf("scrollAnchor should be reset to 0, got %d", m.scrollAnchor) } if m.userScrolledUp { t.Error("userScrolledUp should be reset to false") } } func TestScrollAnchor_ToolMessages(t *testing.T) { m := newTestModel(t) m.state = StateStreaming m.anchorActive = true updated, _ := m.Update(ToolCallStartMsg{ Name: "read_file", Args: map[string]any{"path": "test.go"}, StartTime: testTime, }) m = updated.(*Model) if !m.anchorActive { t.Error("anchorActive should remain true after ToolCallStartMsg") } updated, _ = m.Update(ToolCallResultMsg{ Name: "read_file", Result: "file content", IsError: false, Duration: testDuration, }) m = updated.(*Model) if !m.anchorActive { t.Error("anchorActive should remain true after ToolCallResultMsg") } } func TestScrollAnchor_SystemMessages(t *testing.T) { m := newTestModel(t) m.anchorActive = true updated, _ := m.Update(SystemMessageMsg{Msg: "system message"}) m = updated.(*Model) if !m.anchorActive { t.Error("anchorActive should remain true after SystemMessageMsg") } updated, _ = m.Update(ErrorMsg{Msg: "error message"}) m = updated.(*Model) if !m.anchorActive { t.Error("anchorActive should remain true after ErrorMsg") } } func TestScrollAnchor_WindowResize(t *testing.T) { m := newTestModel(t) updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = updated.(*Model) if !m.anchorActive { t.Fatal("anchorActive should be true after initial sizing") } } func TestCheckAutoScroll_ReenablesAnchorAtBottom(t *testing.T) { m := newTestModel(t) m.anchorActive = false m.userScrolledUp = true m.scrollAnchor = 10 m.viewport.SetContent("short content") m.viewport.GotoBottom() m.checkAutoScroll() if !m.anchorActive { t.Error("checkAutoScroll should set anchorActive to true when at bottom") } if m.userScrolledUp { t.Error("checkAutoScroll should set userScrolledUp to false when at bottom") } if m.scrollAnchor != 0 { t.Error("checkAutoScroll should reset scrollAnchor to 0 when at bottom") } } func TestScrollAnchor_ViewportAtBottom(t *testing.T) { m := newTestModel(t) m.viewport.SetContent("line1\nline2\nline3") if !m.viewport.AtBottom() { t.Error("viewport should be at bottom with short content") } var longContent string for i := 0; i < 100; i++ { longContent += "line " + string(rune(i)) + "\n" } m.viewport.SetContent(longContent) m.viewport.GotoBottom() if !m.viewport.AtBottom() { t.Error("viewport should be at bottom after GotoBottom()") } m.viewport.GotoTop() if m.viewport.AtBottom() { t.Error("viewport should not be at bottom after scrolling to top") } } func BenchmarkScrollAnchor_Performance(b *testing.B) { m := newTestModelB(b) m.anchorActive = true var longContent string for i := 0; i < 100; i++ { longContent += "line " + string(rune(i)) + "\n" } m.viewport.SetContent(longContent) b.ResetTimer() for i := 0; i < b.N; i++ { _ = m.viewport.AtBottom() } } func newTestModelB(b *testing.B) *Model { reg := command.NewRegistry() command.RegisterBuiltins(reg) completer := NewCompleter(reg, []string{"model-a", "model-b"}, []string{"skill-a"}, []string{"agent-x"}, nil) ag := agent.New(nil, nil, 0) m := New(ag, reg, nil, completer, nil, nil, nil) m.initializing = false updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) return updated.(*Model) }