mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
9a662d440c
- remove "You" label and icon from user messages, use borderless content block
- remove input title bar ("Enter your prompt...") and hint line
- increase textarea from 3 to 4 rows with top/bottom margin
- hide input hints permanently for a cleaner UI
- match separator colors (use theme.Border for both startup and input dividers)
- make startup separator full terminal width instead of hardcoded 80
- add /help for help hint and pipe separators to status bar
- add printCustomMessage/RenderCustomMessage for custom alert labels
- render /help output as markdown with "Help" alert label
- add Ctrl+V (paste image) to help message keys section
- fix reasoning text wrapping using ANSI-aware lipgloss.Style.Width
- export HighlightFileTokens for cross-package use
713 lines
24 KiB
Go
713 lines
24 KiB
Go
package ui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/mark3labs/kit/internal/app"
|
|
"github.com/mark3labs/kit/internal/ui/core"
|
|
)
|
|
|
|
// ==========================================================================
|
|
// InputComponent tests
|
|
// ==========================================================================
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
// newTestInput creates an InputComponent with the given AppController (may be nil).
|
|
func newTestInput(ctrl AppController) *InputComponent {
|
|
return NewInputComponent(80, ctrl)
|
|
}
|
|
|
|
// sendInputMsg calls component.Update with the given message, returns the
|
|
// updated component and the cmd.
|
|
func sendInputMsg(c *InputComponent, msg tea.Msg) (*InputComponent, tea.Cmd) {
|
|
m, cmd := c.Update(msg)
|
|
return m.(*InputComponent), cmd
|
|
}
|
|
|
|
// runCmd executes a tea.Cmd and returns the resulting tea.Msg.
|
|
// Returns nil if cmd is nil.
|
|
func runCmd(cmd tea.Cmd) tea.Msg {
|
|
if cmd == nil {
|
|
return nil
|
|
}
|
|
return cmd()
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestInputComponent_SubmitEmitsSubmitMsg verifies that pressing enter on a
|
|
// non-empty textarea emits a submitMsg with the typed text.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestInputComponent_SubmitEmitsSubmitMsg(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
c := newTestInput(ctrl)
|
|
|
|
// Type text directly into the textarea (bypassing key events to keep the
|
|
// test simple — we only care about the submit path here).
|
|
c.textarea.SetValue("hello world")
|
|
c.lastValue = "hello world"
|
|
|
|
// Press enter via key press (no popup visible).
|
|
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
msg := runCmd(cmd)
|
|
if msg == nil {
|
|
t.Fatal("expected a cmd from pressing enter on non-empty input")
|
|
}
|
|
|
|
sm, ok := msg.(core.SubmitMsg)
|
|
if !ok {
|
|
t.Fatalf("expected submitMsg, got %T", msg)
|
|
}
|
|
if sm.Text != "hello world" {
|
|
t.Fatalf("expected Text='hello world', got %q", sm.Text)
|
|
}
|
|
}
|
|
|
|
// TestInputComponent_EmptySubmit_NoCmd verifies that submitting an empty or
|
|
// whitespace-only string produces no cmd.
|
|
func TestInputComponent_EmptySubmit_NoCmd(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
c := newTestInput(ctrl)
|
|
|
|
// textarea is empty by default
|
|
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd != nil {
|
|
t.Fatal("expected nil cmd from submitting empty input")
|
|
}
|
|
}
|
|
|
|
// TestInputComponent_SubmitClearsTextarea verifies that after submit the
|
|
// textarea is cleared.
|
|
func TestInputComponent_SubmitClearsTextarea(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
c := newTestInput(ctrl)
|
|
|
|
c.textarea.SetValue("some text")
|
|
c.lastValue = "some text"
|
|
|
|
c, _ = sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
if c.textarea.Value() != "" {
|
|
t.Fatalf("expected textarea to be cleared after submit, got %q", c.textarea.Value())
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestInputComponent_QuitReturnsTeaQuit verifies that submitting /quit (and its
|
|
// aliases) returns a tea.Quit cmd.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestInputComponent_QuitReturnsTeaQuit(t *testing.T) {
|
|
aliases := []string{"/quit", "/q", "/exit"}
|
|
ctrl := &stubAppController{}
|
|
|
|
for _, alias := range aliases {
|
|
t.Run(alias, func(t *testing.T) {
|
|
c := newTestInput(ctrl)
|
|
c.textarea.SetValue(alias)
|
|
c.lastValue = alias
|
|
|
|
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if cmd == nil {
|
|
t.Fatalf("%s: expected tea.Quit cmd, got nil", alias)
|
|
}
|
|
msg := runCmd(cmd)
|
|
if _, ok := msg.(tea.QuitMsg); !ok {
|
|
t.Fatalf("%s: expected QuitMsg, got %T", alias, msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestInputComponent_ClearForwardsAsSubmitMsg verifies that /clear (and its
|
|
// aliases) are forwarded as submitMsg to the parent model so that the parent
|
|
// can call ClearMessages(), update scrollback, and print the confirmation
|
|
// message in one place. InputComponent must NOT call ClearMessages() directly.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) {
|
|
aliases := []string{"/clear", "/c", "/cls"}
|
|
for _, alias := range aliases {
|
|
t.Run(alias, func(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
c := newTestInput(ctrl)
|
|
c.textarea.SetValue(alias)
|
|
c.lastValue = alias
|
|
|
|
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
// InputComponent must NOT call ClearMessages() directly.
|
|
if ctrl.clearMsgCalled != 0 {
|
|
t.Fatalf("%s: InputComponent must not call ClearMessages(), got %d", alias, ctrl.clearMsgCalled)
|
|
}
|
|
// A submitMsg must be emitted so the parent model handles /clear.
|
|
if cmd == nil {
|
|
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
|
|
}
|
|
msg := runCmd(cmd)
|
|
sm, ok := msg.(core.SubmitMsg)
|
|
if !ok {
|
|
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
|
|
}
|
|
if sm.Text != alias {
|
|
t.Fatalf("%s: expected submitMsg text %q, got %q", alias, alias, sm.Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInputComponent_ClearNilCtrl_NoPanic verifies that /clear with a nil
|
|
// appCtrl does not panic. Since /clear is now forwarded to the parent via
|
|
// submitMsg, no appCtrl interaction happens in InputComponent at all.
|
|
func TestInputComponent_ClearNilCtrl_NoPanic(t *testing.T) {
|
|
c := newTestInput(nil)
|
|
c.textarea.SetValue("/clear")
|
|
c.lastValue = "/clear"
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("unexpected panic on /clear with nil controller: %v", r)
|
|
}
|
|
}()
|
|
|
|
_, _ = sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestInputComponent_ClearQueue_ForwardsAsSubmitMsg verifies that /clear-queue
|
|
// (and its alias /cq) are forwarded as submitMsg to the parent model (so the
|
|
// parent can call ClearQueue and update queueCount directly, avoiding a
|
|
// deadlock from calling prog.Send within Update).
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestInputComponent_ClearQueue_ForwardsAsSubmitMsg(t *testing.T) {
|
|
aliases := []string{"/clear-queue", "/cq"}
|
|
for _, alias := range aliases {
|
|
t.Run(alias, func(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
c := newTestInput(ctrl)
|
|
c.textarea.SetValue(alias)
|
|
c.lastValue = alias
|
|
|
|
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
// ClearQueue should NOT be called directly by InputComponent.
|
|
if ctrl.clearQueueCalled != 0 {
|
|
t.Fatalf("%s: expected ClearQueue() not called, got %d", alias, ctrl.clearQueueCalled)
|
|
}
|
|
// Instead, a submitMsg should be emitted so the parent handles it.
|
|
if cmd == nil {
|
|
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
|
|
}
|
|
msg := runCmd(cmd)
|
|
sm, ok := msg.(core.SubmitMsg)
|
|
if !ok {
|
|
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
|
|
}
|
|
if sm.Text != alias {
|
|
t.Fatalf("%s: expected submitMsg text %q, got %q", alias, alias, sm.Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit verifies that a
|
|
// slash command not in the registry is forwarded as a submitMsg.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) {
|
|
ctrl := &stubAppController{}
|
|
c := newTestInput(ctrl)
|
|
c.textarea.SetValue("/unknown-command")
|
|
c.lastValue = "/unknown-command"
|
|
|
|
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
msg := runCmd(cmd)
|
|
if msg == nil {
|
|
t.Fatal("expected submitMsg for unknown slash command")
|
|
}
|
|
sm, ok := msg.(core.SubmitMsg)
|
|
if !ok {
|
|
t.Fatalf("expected submitMsg for unknown slash command, got %T", msg)
|
|
}
|
|
if sm.Text != "/unknown-command" {
|
|
t.Fatalf("expected Text='/unknown-command', got %q", sm.Text)
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// StreamComponent tests
|
|
// ==========================================================================
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
// newTestStream creates a StreamComponent with a fixed width and model name.
|
|
func newTestStream() *StreamComponent {
|
|
return NewStreamComponent(80, "test-model")
|
|
}
|
|
|
|
// sendStreamMsg calls component.Update and returns the updated component.
|
|
func sendStreamMsg(c *StreamComponent, msg tea.Msg) *StreamComponent {
|
|
m, _ := c.Update(msg)
|
|
return m.(*StreamComponent)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_Init_ReturnsNil verifies Init() returns nil (no startup cmd).
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_Init_ReturnsNil(t *testing.T) {
|
|
c := newTestStream()
|
|
cmd := c.Init()
|
|
if cmd != nil {
|
|
t.Fatal("expected Init() to return nil cmd")
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_SpinnerTransition verifies that SpinnerEvent{Show:true}
|
|
// starts spinning and transitions phase from idle → active.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_SpinnerTransition(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
if c.phase != streamPhaseIdle {
|
|
t.Fatalf("expected streamPhaseIdle initially, got %v", c.phase)
|
|
}
|
|
|
|
_, cmd := c.Update(app.SpinnerEvent{Show: true})
|
|
|
|
if !c.spinning {
|
|
t.Fatal("expected spinning=true after SpinnerEvent{Show:true}")
|
|
}
|
|
if c.phase != streamPhaseActive {
|
|
t.Fatalf("expected streamPhaseActive after SpinnerEvent{Show:true}, got %v", c.phase)
|
|
}
|
|
// A tick cmd should have been returned to start the animation loop.
|
|
if cmd == nil {
|
|
t.Fatal("expected tick cmd from SpinnerEvent{Show:true}")
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_SpinnerShowFalse_NoTransitionFromIdle verifies that
|
|
// SpinnerEvent{Show:false} when idle has no effect.
|
|
func TestStreamComponent_SpinnerShowFalse_NoTransitionFromIdle(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: false})
|
|
|
|
if c.phase != streamPhaseIdle {
|
|
t.Fatalf("expected streamPhaseIdle after SpinnerEvent{Show:false}, got %v", c.phase)
|
|
}
|
|
if c.spinning {
|
|
t.Fatal("expected spinning=false after SpinnerEvent{Show:false}")
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_SpinnerKeepsRunningDuringStreaming verifies that
|
|
// receiving a StreamChunkEvent keeps the spinner running alongside text.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
// Start spinner.
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
|
if !c.spinning {
|
|
t.Fatal("precondition: expected spinning=true")
|
|
}
|
|
|
|
// Receive first chunk — spinner should keep running.
|
|
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
|
|
|
|
// Flush pending chunks (simulates the 16ms tick firing).
|
|
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
|
|
|
|
if !c.spinning {
|
|
t.Fatal("expected spinning=true after first chunk")
|
|
}
|
|
if c.phase != streamPhaseActive {
|
|
t.Fatalf("expected streamPhaseActive after first chunk, got %v", c.phase)
|
|
}
|
|
if c.streamContent.String() != "hello" {
|
|
t.Fatalf("expected streamContent='hello', got %q", c.streamContent.String())
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_ChunkAccumulation verifies that multiple StreamChunkEvents
|
|
// accumulate in order.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_ChunkAccumulation(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
chunks := []string{"Hello", ", ", "world", "!"}
|
|
for _, chunk := range chunks {
|
|
c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
|
|
}
|
|
|
|
// Flush pending chunks (simulates the 16ms tick firing).
|
|
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
|
|
|
|
got := c.streamContent.String()
|
|
want := "Hello, world!"
|
|
if got != want {
|
|
t.Fatalf("expected accumulated content %q, got %q", want, got)
|
|
}
|
|
if c.phase != streamPhaseActive {
|
|
t.Fatalf("expected streamPhaseActive, got %v", c.phase)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_ToolExecution_IsStarting shows spinner during execution.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
_, cmd := c.Update(app.ToolExecutionEvent{
|
|
ToolCallID: "call-exec-1",
|
|
ToolName: "exec_tool",
|
|
IsStarting: true,
|
|
})
|
|
|
|
if !c.spinning {
|
|
t.Fatal("expected spinning=true during tool execution")
|
|
}
|
|
tools := c.activeToolDisplays()
|
|
if len(tools) != 1 || !strings.Contains(tools[0], "exec_tool") {
|
|
t.Fatalf("expected activeTools to contain tool name, got %v", tools)
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_ToolExecution_NotStarting clears label but keeps spinning.
|
|
func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
|
|
c := newTestStream()
|
|
// Start spinning first (simulating execution in progress).
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
|
// Simulate a tool starting
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
|
ToolCallID: "call-some-1",
|
|
ToolName: "some_tool",
|
|
IsStarting: true,
|
|
})
|
|
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
|
ToolCallID: "call-some-1",
|
|
ToolName: "some_tool",
|
|
IsStarting: false,
|
|
})
|
|
|
|
if !c.spinning {
|
|
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
|
|
}
|
|
if len(c.activeTools) != 0 {
|
|
t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
|
|
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
// Start three tools in parallel
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: true})
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: true})
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: true})
|
|
|
|
if len(c.activeTools) != 3 {
|
|
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
|
|
}
|
|
|
|
// Check SpinnerView shows all tools
|
|
view := c.SpinnerView()
|
|
if !strings.Contains(view, "Running:") {
|
|
t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
|
|
}
|
|
|
|
// Finish one tool
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: false})
|
|
if len(c.activeTools) != 2 {
|
|
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
|
|
}
|
|
|
|
// Finish remaining tools
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: false})
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: false})
|
|
if len(c.activeTools) != 0 {
|
|
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_ParallelSameToolName_UsesToolCallID verifies finishing one
|
|
// tool call does not remove another concurrent call with the same tool name.
|
|
func TestStreamComponent_ParallelSameToolName_UsesToolCallID(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: true})
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: true})
|
|
|
|
tools := c.activeToolDisplays()
|
|
if len(tools) != 2 {
|
|
t.Fatalf("expected 2 active read calls, got %d (%v)", len(tools), tools)
|
|
}
|
|
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: false})
|
|
tools = c.activeToolDisplays()
|
|
if len(tools) != 1 {
|
|
t.Fatalf("expected 1 active read call after finishing one ID, got %d (%v)", len(tools), tools)
|
|
}
|
|
|
|
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: false})
|
|
if len(c.activeToolDisplays()) != 0 {
|
|
t.Fatalf("expected no active tools after finishing both IDs, got %v", c.activeToolDisplays())
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
|
|
// text when content is accumulated, and empty string when not.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_GetRenderedContent_Empty(t *testing.T) {
|
|
c := newTestStream()
|
|
if got := c.GetRenderedContent(); got != "" {
|
|
t.Fatalf("expected empty GetRenderedContent on idle component, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStreamComponent_GetRenderedContent_WithText(t *testing.T) {
|
|
c := newTestStream()
|
|
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello world"})
|
|
got := c.GetRenderedContent()
|
|
if got == "" {
|
|
t.Fatal("expected non-empty GetRenderedContent after chunks")
|
|
}
|
|
// The rendered output contains ANSI escape codes from the message renderer,
|
|
// so check for the text fragments rather than an exact substring.
|
|
if !strings.Contains(got, "hello") {
|
|
t.Fatalf("expected rendered content to contain 'hello', got %q", got)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_Reset clears all accumulated state.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_Reset(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
// Accumulate some state.
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
|
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "some text"})
|
|
c.spinnerFrame = 5
|
|
|
|
c.Reset()
|
|
|
|
if c.phase != streamPhaseIdle {
|
|
t.Fatalf("expected streamPhaseIdle after Reset(), got %v", c.phase)
|
|
}
|
|
if c.spinning {
|
|
t.Fatal("expected spinning=false after Reset()")
|
|
}
|
|
if c.spinnerFrame != 0 {
|
|
t.Fatalf("expected spinnerFrame=0 after Reset(), got %d", c.spinnerFrame)
|
|
}
|
|
if c.streamContent.String() != "" {
|
|
t.Fatalf("expected empty streamContent after Reset(), got %q", c.streamContent.String())
|
|
}
|
|
if !c.timestamp.IsZero() {
|
|
t.Fatal("expected zero timestamp after Reset()")
|
|
}
|
|
if len(c.activeTools) != 0 {
|
|
t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_SetHeight propagates to height field.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_SetHeight(t *testing.T) {
|
|
c := newTestStream()
|
|
c.SetHeight(20)
|
|
if c.height != 20 {
|
|
t.Fatalf("expected height=20, got %d", c.height)
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_SetHeight_Negative_ClampsToZero verifies negative values
|
|
// are clamped to 0.
|
|
func TestStreamComponent_SetHeight_Negative_ClampsToZero(t *testing.T) {
|
|
c := newTestStream()
|
|
c.SetHeight(-5)
|
|
if c.height != 0 {
|
|
t.Fatalf("expected height=0 for negative input, got %d", c.height)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// TestStreamComponent_SpinnerTick advances the frame counter.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
// Start spinning first.
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
|
initialFrame := c.spinnerFrame
|
|
gen := c.spinnerGeneration
|
|
|
|
// Send a tick with the current generation.
|
|
_, cmd := c.Update(streamSpinnerTickMsg{generation: gen})
|
|
|
|
if c.spinnerFrame != initialFrame+1 {
|
|
t.Fatalf("expected spinnerFrame=%d, got %d", initialFrame+1, c.spinnerFrame)
|
|
}
|
|
// The tick should re-schedule itself while spinning.
|
|
if cmd == nil {
|
|
t.Fatal("expected tick cmd to be re-scheduled while spinning")
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning verifies that a
|
|
// tick when not spinning does not re-schedule.
|
|
func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning(t *testing.T) {
|
|
c := newTestStream()
|
|
// spinning is false — tick should be ignored.
|
|
_, cmd := c.Update(streamSpinnerTickMsg{})
|
|
if cmd != nil {
|
|
t.Fatal("expected no tick reschedule when not spinning")
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_StaleTick_Discarded verifies that a tick from a previous
|
|
// spinner generation is silently discarded, preventing duplicate concurrent
|
|
// tick loops that would double the animation speed.
|
|
func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
// Start spinner → generation 1.
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
|
staleGen := c.spinnerGeneration
|
|
|
|
// Stop spinner → generation bumped to 2.
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: false})
|
|
|
|
// Restart spinner → generation bumped to 3.
|
|
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
|
currentGen := c.spinnerGeneration
|
|
frameBefore := c.spinnerFrame
|
|
|
|
// Simulate a stale tick from the first spinner session arriving.
|
|
_, cmd := c.Update(streamSpinnerTickMsg{generation: staleGen})
|
|
if c.spinnerFrame != frameBefore {
|
|
t.Fatalf("stale tick should not advance frame: expected %d, got %d", frameBefore, c.spinnerFrame)
|
|
}
|
|
if cmd != nil {
|
|
t.Fatal("stale tick should not reschedule")
|
|
}
|
|
|
|
// A tick from the current generation should still work.
|
|
_, cmd = c.Update(streamSpinnerTickMsg{generation: currentGen})
|
|
if c.spinnerFrame != frameBefore+1 {
|
|
t.Fatalf("current-gen tick should advance frame: expected %d, got %d", frameBefore+1, c.spinnerFrame)
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("current-gen tick should reschedule")
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_StaleFlushTick_Discarded verifies that flush ticks from a
|
|
// previous generation (e.g. pre-Reset) are ignored.
|
|
func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
|
|
c := newTestStream()
|
|
|
|
// Start a pending flush and capture its generation.
|
|
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "old"})
|
|
staleGen := c.flushGeneration
|
|
if !c.flushPending {
|
|
t.Fatal("precondition: expected flushPending=true after first chunk")
|
|
}
|
|
|
|
// Reset should invalidate in-flight flush ticks.
|
|
c.Reset()
|
|
if c.flushGeneration == staleGen {
|
|
t.Fatal("expected flushGeneration to change after Reset")
|
|
}
|
|
|
|
// New content in a new generation.
|
|
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "new"})
|
|
if got := c.pendingStream.String(); got != "new" {
|
|
t.Fatalf("expected pendingStream='new', got %q", got)
|
|
}
|
|
|
|
// Stale flush tick should be ignored.
|
|
c = sendStreamMsg(c, streamFlushTickMsg{generation: staleGen})
|
|
if got := c.pendingStream.String(); got != "new" {
|
|
t.Fatalf("stale flush tick should not commit pending stream, got %q", got)
|
|
}
|
|
|
|
// Current generation flush should commit.
|
|
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
|
|
if got := c.pendingStream.String(); got != "" {
|
|
t.Fatalf("expected pendingStream empty after current flush, got %q", got)
|
|
}
|
|
if got := c.streamContent.String(); got != "new" {
|
|
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
|
|
// unconstrained (0), ConsumeOverflow always returns "".
|
|
func TestStreamComponent_ConsumeOverflow_NoOp(t *testing.T) {
|
|
c := newTestStream()
|
|
// Commit some content directly.
|
|
c.streamContent.WriteString("line1\nline2\nline3")
|
|
c.phase = streamPhaseActive
|
|
|
|
// ConsumeOverflow is a no-op in alt screen mode — always returns "".
|
|
if got := c.ConsumeOverflow(); got != "" {
|
|
t.Fatalf("expected empty from no-op ConsumeOverflow, got %q", got)
|
|
}
|
|
|
|
// Also returns "" with a height set.
|
|
c.height = 2
|
|
if got := c.ConsumeOverflow(); got != "" {
|
|
t.Fatalf("expected empty from no-op ConsumeOverflow with height, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestStreamComponent_GetRenderedContent_ReturnsAll verifies that
|
|
// GetRenderedContent returns all accumulated content.
|
|
func TestStreamComponent_GetRenderedContent_ReturnsAll(t *testing.T) {
|
|
c := newTestStream()
|
|
c.renderer = nil
|
|
c.phase = streamPhaseActive
|
|
|
|
c.streamContent.WriteString("a\nb\nc\nd\ne")
|
|
|
|
got := c.GetRenderedContent()
|
|
if got != "a\nb\nc\nd\ne" {
|
|
t.Fatalf("expected full content, got %q", got)
|
|
}
|
|
}
|