325 lines
11 KiB
Go
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")
|
|
}
|
|
}
|