Files
2026-07-13 13:00:08 +08:00

369 lines
12 KiB
Go

package cli
import (
"context"
"os/exec"
"runtime"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/agent"
"reasonix/internal/agent/testutil"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/i18n"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// TestRunStatuslineCmd checks the custom status-line runner: it returns the
// first stdout line and forwards the JSON payload on stdin.
func TestRunStatuslineCmd(t *testing.T) {
firstLineCmd := "printf 'row-one\\nrow-two\\n'"
stdinCmd := "cat"
failCmd := "exit 3"
if runtime.GOOS == "windows" {
firstLineCmd = "echo row-one & echo row-two"
stdinCmd = "more"
failCmd = "exit /b 3"
}
// Multi-line output collapses to the first row.
if got := runStatuslineCmd(firstLineCmd, "{}"); got != "row-one" {
t.Errorf("multi-line output should collapse to the first row, got %q", got)
}
// The JSON payload is delivered on stdin.
if got := runStatuslineCmd(stdinCmd, `{"model":"deepseek"}`); got != `{"model":"deepseek"}` {
t.Errorf("stdin payload not forwarded, got %q", got)
}
// A failing command yields an empty line, not an error.
if got := runStatuslineCmd(failCmd, "{}"); got != "" {
t.Errorf("failed command should yield empty, got %q", got)
}
}
func TestRunStatuslineCmdNormalizesQuotedNodeEval(t *testing.T) {
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node not available")
}
script := "let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const payload = JSON.parse(input); console.log(payload.model) })"
cmd := `node -e "\"` + script + `\""`
timeout := statuslineCommandTimeout
if runtime.GOOS == "windows" {
// Windows CI cold-starts node.exe through Defender scanning while the
// rest of the module compiles and tests in parallel; a fresh toolchain
// (empty setup-go cache) pushes that past 10s. The production timeout
// is not under test here — only the quoted-eval normalization is.
timeout = 30 * time.Second
}
if got := runStatuslineCmdWithTimeout(cmd, `{"model":"deepseek"}`, timeout); got != "deepseek" {
t.Fatalf("normalized statusline node -e output = %q, want deepseek", got)
}
}
// TestRunStatuslineDisabled confirms no command means no work (nil cmd), without
// touching the controller.
func TestRunStatuslineDisabled(t *testing.T) {
m := chatTUI{} // no statuslineCmd, nil ctrl
if cmd := m.runStatusline(); cmd != nil {
t.Error("an unconfigured status line must return a nil tea.Cmd")
}
}
func TestModelSwitchRefreshesCustomStatusline(t *testing.T) {
oldCtrl := control.New(control.Options{Label: "old-model"})
newCtrl := control.New(control.Options{Label: "new-model"})
m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 80)
m.statuslineCmd = "cat"
m.statuslineOut = `{"model":"old-model"}`
_, cmd := m.Update(modelSwitchMsg{
ref: "provider/new-model",
ctrl: newCtrl,
label: "new-model",
})
if cmd == nil {
t.Fatal("model switch should schedule commands")
}
if !statuslineCommandHasModel(cmd, "new-model") {
t.Fatal("model switch did not refresh custom statusline with the new model")
}
}
func statuslineCommandHasModel(cmd tea.Cmd, model string) bool {
msg := cmd()
switch msg := msg.(type) {
case statuslineMsg:
return strings.Contains(msg.out, `"model":"`+model+`"`)
case tea.BatchMsg:
for _, child := range msg {
if child == nil {
continue
}
if statuslineCommandHasModel(child, model) {
return true
}
}
}
return false
}
func TestIdleStatuslineIsCompact(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineView(t, false)
plain := bottomStatusPlain(content)
if !strings.Contains(plain, "Auto") || !strings.Contains(plain, "ready") {
t.Fatalf("idle status line missing mode status:\n%s", plain)
}
if !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
t.Fatalf("idle status line missing plan-toggle hint:\n%s", plain)
}
for _, old := range []string{"Shift-Tab", "Ctrl-O", "Ctrl-D", "Enter sends", "Esc clears/exits state", "PgUp/PgDn"} {
if strings.Contains(plain, old) {
t.Fatalf("idle status line should not contain %q:\n%s", old, plain)
}
}
if strings.Contains(plain, "[auto]") {
t.Fatalf("idle status line should use pill label, not bracketed tag:\n%s", plain)
}
if !strings.Contains(content, "\x1b[48;2;245;158;11m") {
t.Fatalf("Auto status line should use amber pill background, got:\n%q", content)
}
}
func TestYoloStatuslineUsesDangerPill(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineView(t, true)
plain := bottomStatusPlain(content)
if !strings.Contains(plain, "YOLO") || !strings.Contains(plain, "approvals skipped") || !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
t.Fatalf("YOLO status line missing warning text:\n%s", plain)
}
if strings.Contains(plain, "[YOLO]") {
t.Fatalf("YOLO status line should use a pill label, not bracketed tag:\n%s", plain)
}
if !strings.Contains(content, "\x1b[48;2;229;72;77m") {
t.Fatalf("YOLO status line should use danger pill background, got:\n%q", content)
}
}
func TestPlanStatuslineUsesBluePill(t *testing.T) {
i18n.DetectLanguage("en")
content := renderPlanStatuslineView(t)
plain := bottomStatusPlain(content)
if !strings.Contains(plain, "Plan") || !strings.Contains(plain, "ready") || !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
t.Fatalf("plan status line missing mode status:\n%s", plain)
}
if !strings.Contains(content, "\x1b[48;2;37;99;235m") {
t.Fatalf("Plan status line should use blue pill background, got:\n%q", content)
}
}
func TestStatuslineCycleHintFollowsLanguage(t *testing.T) {
i18n.DetectLanguage("zh")
t.Cleanup(func() { i18n.DetectLanguage("en") })
content := renderStatuslineView(t, false)
plain := bottomStatusPlain(content)
if !strings.Contains(plain, "Auto") || !strings.Contains(plain, "就绪") || !strings.Contains(plain, "(shift+tab 切换计划 · ctrl+y yolo)") {
t.Fatalf("localized plan-toggle hint missing:\n%s", plain)
}
if strings.Contains(plain, "ready") || strings.Contains(plain, "shift+tab toggles plan · ctrl+y yolo") {
t.Fatalf("localized status line should not fall back to English:\n%s", plain)
}
}
func TestDesktopShortcutStatuslineUsesPlanToggleHint(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineViewWithShortcutLayout(t, "desktop")
plain := bottomStatusPlain(content)
if !strings.Contains(plain, "Ask") || !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
t.Fatalf("desktop shortcut status line missing unified plan-toggle hint:\n%s", plain)
}
if strings.Contains(plain, "ask/auto/plan") {
t.Fatalf("desktop shortcut status line should not advertise Ask/Auto/Plan cycling:\n%s", plain)
}
}
func TestStatuslineShowsEffort(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineViewWithEffort(t, "auto")
lines := bottomStatusPlainLines(content)
if len(lines) != 2 {
t.Fatalf("status block lines = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
}
if !strings.Contains(lines[0], "effort auto") {
t.Fatalf("mode row should show effort:\n%s", strings.Join(lines, "\n"))
}
if strings.Contains(lines[1], "effort auto") {
t.Fatalf("data row should not show effort:\n%s", strings.Join(lines, "\n"))
}
}
func TestStatuslineKeepsCacheRatesInPrimaryDataRow(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineViewWithCache(t)
lines := bottomStatusPlainLines(content)
if len(lines) != 2 {
t.Fatalf("status block lines = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
}
want := "deepseek-v4-flash · turn hit 90.00% · avg 90.00%"
if !strings.Contains(lines[1], want) {
t.Fatalf("data row should keep cache rates next to model:\n%s", strings.Join(lines, "\n"))
}
}
func TestStatuslinePutsGitIdentityOnModeRow(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineViewWithGitAndEffort(t)
lines := bottomStatusPlainLines(content)
if len(lines) != 2 {
t.Fatalf("status block lines = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
}
if !strings.Contains(lines[0], "effort auto · Reasonix@codex/demo (+3 -1 ?2)") {
t.Fatalf("mode row should include effort before git identity:\n%s", strings.Join(lines, "\n"))
}
if strings.Contains(lines[1], "Reasonix@codex/demo") {
t.Fatalf("data row should not include git identity:\n%s", strings.Join(lines, "\n"))
}
if !strings.Contains(lines[1], "deepseek-v4-flash") || strings.Contains(lines[1], "effort auto") {
t.Fatalf("data row should keep model without effort:\n%s", strings.Join(lines, "\n"))
}
}
func TestStatuslineExplicitEffortUsesBlue(t *testing.T) {
i18n.DetectLanguage("en")
content := renderStatuslineViewWithEffort(t, "max")
plain := bottomStatusPlain(content)
if !strings.Contains(plain, "effort max") {
t.Fatalf("status data line should show explicit effort:\n%s", plain)
}
if !strings.Contains(content, "\x1b[1;38;2;37;99;235m") {
t.Fatalf("explicit effort should use blue foreground, got:\n%q", content)
}
}
func TestRefreshEffortStatusUsesCurrentModel(t *testing.T) {
isolateUserConfig(t)
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.refreshEffortStatus()
if m.effortLevel != "auto" {
t.Fatalf("effortLevel = %q, want auto", m.effortLevel)
}
}
func renderStatuslineView(t *testing.T, yolo bool) string {
t.Helper()
ctrl := control.New(control.Options{})
ctrl.SetAutoApproveTools(yolo)
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return next.(chatTUI).View().Content
}
func renderStatuslineViewWithShortcutLayout(t *testing.T, layout string) string {
t.Helper()
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
m.cfg = config.Default()
if err := m.cfg.SetUIShortcutLayout(layout); err != nil {
t.Fatal(err)
}
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return next.(chatTUI).View().Content
}
func renderStatuslineViewWithEffort(t *testing.T, effort string) string {
t.Helper()
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
m.label = "deepseek-v4-flash"
m.effortLevel = effort
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return next.(chatTUI).View().Content
}
func renderStatuslineViewWithGitAndEffort(t *testing.T) string {
t.Helper()
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
m.label = "deepseek-v4-flash"
m.effortLevel = "auto"
m.gitStatus = gitStatus{
Repo: "Reasonix",
Branch: "codex/demo",
Added: 3,
Removed: 1,
Untracked: 2,
}
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
return next.(chatTUI).View().Content
}
func renderStatuslineViewWithCache(t *testing.T) string {
t.Helper()
prov := testutil.NewMock("deepseek-v4-flash", testutil.Turn{
Text: "ok",
Usage: &provider.Usage{
CacheHitTokens: 900,
CacheMissTokens: 100,
CompletionTokens: 50,
PromptTokens: 1000,
TotalTokens: 1050,
},
})
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{MaxSteps: 1, ContextWindow: 200_000}, event.Discard)
if err := exec.Run(context.Background(), "hello"); err != nil {
t.Fatalf("seed agent usage: %v", err)
}
ctrl := control.New(control.Options{Executor: exec})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 160)
m.label = "deepseek-v4-flash"
m.effortLevel = "auto"
next, _ := m.Update(tea.WindowSizeMsg{Width: 160, Height: 24})
return next.(chatTUI).View().Content
}
func renderPlanStatuslineView(t *testing.T) string {
t.Helper()
ctrl := control.New(control.Options{})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
m.planMode = true
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return next.(chatTUI).View().Content
}
func bottomStatusPlain(content string) string {
return strings.Join(bottomStatusPlainLines(content), "\n")
}
func bottomStatusPlainLines(content string) []string {
lines := strings.Split(ansi.Strip(content), "\n")
if len(lines) < 2 {
return lines
}
return lines[len(lines)-2:]
}