Files
2026-08-14 23:41:57 +08:00

325 lines
11 KiB
Go

package tui
import (
"regexp"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli/ui"
)
// ansiRE strips SGR escape sequences so tests can inspect the raw text the
// transcript stored, independent of the theme's coloring.
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") }
// apply runs one Update tick and returns the concrete Model, failing on an
// unexpected model type. It keeps the streaming tests terse.
func apply(t *testing.T, m tea.Model, msg tea.Msg) Model {
t.Helper()
next, _ := m.Update(msg)
got, ok := next.(Model)
if !ok {
t.Fatalf("Update returned %T, want tui.Model", next)
}
return got
}
// TestTranscriptStreamingConcat feeds a run of text deltas then a turn end and
// asserts the assistant block accumulates the deltas in order and the joined
// text is rendered in the View.
func TestTranscriptStreamingConcat(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, textDeltaMsg{delta: "Hello "})
m = apply(t, m, textDeltaMsg{delta: "world"})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
Content: agentcore.ContentList{agentcore.NewTextContent("Hello world")},
}})
if n := len(m.transcript.blocks); n != 1 {
t.Fatalf("block count = %d, want 1 assistant block", n)
}
if got := m.transcript.blocks[0]; got.role != roleAssistant || got.text != "Hello world" {
t.Errorf("assistant block = %+v, want role assistant text %q", got, "Hello world")
}
if content := stripANSI(m.View().Content); !strings.Contains(content, "Hello world") {
t.Errorf("rendered View missing streamed text; got:\n%s", content)
}
// The turn was finalized, so a fresh delta starts a NEW assistant block.
if m.transcript.activeAssistant != -1 {
t.Errorf("activeAssistant = %d after turn end, want -1", m.transcript.activeAssistant)
}
}
// TestTranscriptSurfacesTurnError verifies a turn that ends with stopReason
// error surfaces the provider's error message as a system block rather than
// finalizing an empty turn and returning silently to the prompt. The loop
// delivers request failures (e.g. a 4xx) this way — as a terminal assistant
// message via TurnEndEvent, not as the run's result error — so without the
// StopReason check in the turnEndMsg handler the TUI would show nothing at all.
func TestTranscriptSurfacesTurnError(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonError,
ErrorMessage: "upstream 401: 无效的令牌",
}})
var sys string
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
sys = b.text
}
}
if !strings.Contains(sys, "error:") || !strings.Contains(sys, "upstream 401: 无效的令牌") {
t.Errorf("turn error not surfaced; system block = %q", sys)
}
if content := stripANSI(m.View().Content); !strings.Contains(content, "upstream 401") {
t.Errorf("rendered View missing the surfaced error; got:\n%s", content)
}
}
// TestTranscriptSurfacesAbortedTurn verifies a turn that ends with stopReason
// aborted is flagged rather than returning silently.
func TestTranscriptSurfacesAbortedTurn(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonAborted,
}})
var sys string
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
sys = b.text
}
}
if !strings.Contains(sys, "aborted") {
t.Errorf("aborted turn not surfaced; system block = %q", sys)
}
}
// TestTranscriptNotesEmptyResponse verifies a clean end_turn that produced no
// content and no tool results is flagged with a note (with a provider-mismatch
// hint) instead of returning silently to the prompt — the shape produced when an
// endpoint accepts the request with a 200 but returns nothing decodable.
func TestTranscriptNotesEmptyResponse(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonEndTurn,
}})
var sys string
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
sys = b.text
}
}
if !strings.Contains(sys, "empty response from the model") {
t.Errorf("empty response not flagged; system block = %q", sys)
}
}
// TestTranscriptCleanTurnNoNote verifies a normal turn with content does NOT add
// a spurious error/empty system note.
func TestTranscriptCleanTurnNoNote(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonEndTurn,
Content: agentcore.ContentList{agentcore.NewTextContent("the answer")},
}})
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
t.Errorf("clean turn should add no system note, got %q", b.text)
}
}
}
// TestTranscriptAutoStick verifies the stick-to-bottom rule: while the viewport
// is at the bottom, new content keeps it pinned there; once the user scrolls up,
// streamed content no longer forces a jump to the bottom — but submitting a new
// turn (addUser) re-arms follow and snaps back to the newest output.
func TestTranscriptAutoStick(t *testing.T) {
tr := newTranscript(DefaultTheme())
tr.setSize(20, 3) // 3 visible rows
for i := 0; i < 6; i++ {
tr.addUser("line")
}
if !tr.vp.AtBottom() {
t.Fatal("transcript should stick to the bottom while at the bottom")
}
// More content while pinned keeps it pinned.
tr.addUser("more")
if !tr.vp.AtBottom() {
t.Fatal("new content should keep a bottom-pinned transcript at the bottom")
}
// Simulate a user scroll-up through the viewport's key handling.
tr.update(tea.KeyPressMsg{Code: tea.KeyUp})
if tr.vp.AtBottom() {
t.Fatal("scrolling up should move the viewport off the bottom")
}
// Streamed content arriving while scrolled up must NOT yank the view back to
// the bottom — the user is reading history.
tr.appendDelta("streamed while reading history\nsecond line\nthird line")
if tr.vp.AtBottom() {
t.Error("auto-stick should stay paused after the user scrolls up")
}
// Submitting a new turn is an explicit action: it re-arms follow and snaps
// back to the newest output so the reply is never left off-screen.
tr.addUser("a brand new prompt")
if !tr.vp.AtBottom() {
t.Error("submitting a new turn should re-arm auto-scroll to the bottom")
}
}
// TestTranscriptCJKWrap feeds a long CJK line into a narrow transcript and
// asserts every wrapped line fits the width in display columns (not bytes) and
// that no rune was dropped or split.
func TestTranscriptCJKWrap(t *testing.T) {
const width = 10
tr := newTranscript(DefaultTheme())
tr.setSize(width, 20)
line := strings.Repeat("你好世界", 5) // 20 CJK runes = 40 display columns
tr.addUser(line)
content := tr.vp.GetContent()
lines := strings.Split(content, "\n")
if len(lines) < 2 {
t.Fatalf("expected the CJK line to wrap onto multiple rows, got %d line(s)", len(lines))
}
for i, ln := range lines {
if w := ui.Width(ln); w > width {
t.Errorf("wrapped line %d width = %d columns, want <= %d: %q", i, w, width, stripANSI(ln))
}
}
// No rune was cut or dropped: every source rune survives the wrap.
if got := strings.Count(stripANSI(content), "你"); got != 5 {
t.Errorf("counted %d 你 runes after wrap, want 5", got)
}
}
// TestTranscriptScrollbar verifies the scrollbar policy: the gutter is hidden
// while the content fits (nothing to scroll) and appears only once the content
// overflows. When overflowing, a rounded pill thumb (body "█" with half-block
// caps "▄"/"▀") sits alongside the thin groove "│".
func TestTranscriptScrollbar(t *testing.T) {
tr := newTranscript(DefaultTheme())
tr.setSize(20, 4) // 4 visible rows
// Two short lines fit in 4 rows: no scrollbar at all — no thumb, no groove.
tr.addUser("one")
tr.addUser("two")
if tr.overflowing() {
t.Fatal("transcript should not overflow while content fits")
}
fit := stripANSI(tr.view())
if strings.ContainsAny(fit, "█▄▀│") {
t.Errorf("expected no scrollbar glyphs while content fits; got:\n%q", fit)
}
// Enough lines to exceed 4 rows: now it overflows, thumb shrinks and the
// groove appears.
for i := 0; i < 10; i++ {
tr.addUser("line")
}
if !tr.overflowing() {
t.Fatal("transcript should overflow once content exceeds the viewport")
}
view := stripANSI(tr.view())
if !strings.Contains(view, "▄") || !strings.Contains(view, "▀") {
t.Errorf("expected a rounded pill thumb (▄ top, ▀ bottom) while overflowing; got:\n%q", view)
}
if !strings.Contains(view, "│") {
t.Errorf("expected a groove │ while overflowing; got:\n%q", view)
}
if strings.Contains(view, "░") {
t.Errorf("scrollbar no longer uses the shaded track ░; got:\n%q", view)
}
}
// TestTranscriptScrollToRow checks the click/drag mapping: pressing the top of
// the gutter scrolls to the top, the bottom scrolls to the bottom, and it is a
// no-op when the content fits.
func TestTranscriptScrollToRow(t *testing.T) {
tr := newTranscript(DefaultTheme())
tr.setSize(20, 4)
// Content fits: dragging must not move a non-scrollable viewport.
tr.addUser("only line")
tr.scrollToRow(3)
if tr.vp.YOffset() != 0 {
t.Errorf("scrollToRow on non-overflowing viewport moved offset to %d, want 0", tr.vp.YOffset())
}
for i := 0; i < 20; i++ {
tr.addUser("line")
}
if !tr.overflowing() {
t.Fatal("expected overflow after filling the transcript")
}
tr.scrollToRow(0)
if !tr.vp.AtTop() {
t.Errorf("dragging to row 0 should scroll to the top; YOffset=%d", tr.vp.YOffset())
}
tr.scrollToRow(tr.viewportHeight() - 1)
if !tr.vp.AtBottom() {
t.Errorf("dragging to the last row should scroll to the bottom; YOffset=%d", tr.vp.YOffset())
}
}
// TestModelScrollbarDrag drives the model with mouse press/motion/release on the
// scrollbar column and asserts the drag state toggles and the viewport scrolls.
func TestModelScrollbarDrag(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 30, Height: 8})
for i := 0; i < 40; i++ {
m.transcript.addUser("line")
}
if !m.transcript.overflowing() {
t.Fatal("expected the transcript to overflow")
}
col := m.width - 1
// Press at the top of the gutter: drag begins and the view jumps to the top.
m = apply(t, m, tea.MouseClickMsg{X: col, Y: 0, Button: tea.MouseLeft})
if !m.draggingScrollbar {
t.Fatal("left press on the scrollbar column should start dragging")
}
if !m.transcript.vp.AtTop() {
t.Errorf("press at row 0 should scroll to top; YOffset=%d", m.transcript.vp.YOffset())
}
// Motion to the bottom row while held drags the thumb down.
m = apply(t, m, tea.MouseMotionMsg{X: col, Y: m.transcript.viewportHeight() - 1, Button: tea.MouseLeft})
if !m.transcript.vp.AtBottom() {
t.Errorf("motion to the last row while dragging should scroll to bottom; YOffset=%d", m.transcript.vp.YOffset())
}
// Release ends the drag.
m = apply(t, m, tea.MouseReleaseMsg{X: col, Y: 3, Button: tea.MouseLeft})
if m.draggingScrollbar {
t.Error("release should end the scrollbar drag")
}
// A press away from the gutter column must not start a drag.
m = apply(t, m, tea.MouseClickMsg{X: 0, Y: 0, Button: tea.MouseLeft})
if m.draggingScrollbar {
t.Error("press off the scrollbar column should not start dragging")
}
}