Files
kit/internal/ui/children_test.go
Ed Zynda 9a662d440c fix(ui): reduce TUI visual noise and improve layout
- 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
2026-04-22 11:41:09 +03:00

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)
}
}