// Screen replacement and modal races depend on command delivery order, which
// daemon E2E cannot control. These tests drive the actual Bubble Tea models.
package tui
import (
"albedo/cli/internal/daemon"
"errors"
"strings"
"testing"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
)
func TestReopenedScreensRejectPreviousRequestReplies(t *testing.T) {
for _, replyErr := range []error{nil, errors.New("discarded request failed")} {
t.Run("page", func(t *testing.T) {
old := NewPageViewModel(nil, "s", "/notes")
old, _ = old.Update(pageLoadedMsg{Gen: old.Generation, Err: errors.New("retry")})
old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
current := NewPageViewModel(nil, "s", "/notes")
current, cmd := current.Update(pageLoadedMsg{Gen: old.Generation, Err: replyErr, Doc: &PageDocument{Title: "obsolete"}})
if !current.Busy || current.Doc != nil || current.Error != "" || cmd != nil {
t.Fatal("old load changed reopened page")
}
current, cmd = current.Update(pageActionExecutedMsg{Gen: old.Generation, Err: replyErr})
if !current.Busy || current.Error != "" || cmd != nil {
t.Fatal("old action completed reopened page")
}
current, _ = current.Update(pageLoadedMsg{Gen: current.Generation, Doc: &PageDocument{Title: "current"}})
if current.Busy || current.Doc.Title != "current" {
t.Fatal("current load was rejected")
}
})
t.Run("extensions", func(t *testing.T) {
old := NewExtensionPickerModel(nil, "s")
old, _ = old.Update(extensionsLoadedMsg{Gen: old.Generation, Err: errors.New("retry")})
old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
current := NewExtensionPickerModel(nil, "s")
current.Saving = true
current, _ = current.Update(extensionsLoadedMsg{Gen: old.Generation, Err: replyErr, Extensions: []ExtensionItem{{Name: "obsolete"}}})
current, cmd := current.Update(extensionToggledMsg{Gen: old.Generation, Err: replyErr})
if !current.Loading || !current.Saving || len(current.Extensions) != 0 || current.Error != "" || cmd != nil {
t.Fatal("old reply changed reopened extensions")
}
})
t.Run("tree", func(t *testing.T) {
old := NewTreePickerModel(nil, "s")
old, _ = old.loadPage(10)
current := NewTreePickerModel(nil, "s")
current.Forking = true
current, _ = current.Update(treeLoadedMsg{Gen: old.Generation, Err: replyErr})
current, cmd := current.Update(treeForkedMsg{Gen: old.Generation, Err: replyErr})
if !current.Loading || !current.Forking || current.Error != "" || current.ForkError != "" || cmd != nil {
t.Fatal("old reply changed reopened tree")
}
})
t.Run("context", func(t *testing.T) {
old := NewContextInspectorModel(nil, "s")
old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
current := NewContextInspectorModel(nil, "s")
current.Detail = &ContextDetail{Section: daemon.ContextSection{ID: "history"}}
current, _ = current.Update(contextSnapshotLoadedMsg{Gen: old.Generation, Err: replyErr, Snapshot: &daemon.ContextSnapshot{State: "ready"}})
current, _ = current.Update(contextPageLoadedMsg{Gen: old.Generation, Err: replyErr, SectionID: "history", Data: &daemon.ContextPage{Content: "obsolete"}})
if !current.Loading || current.Snapshot != nil || current.Error != "" || current.Detail.Value != nil || current.Detail.Error != "" {
t.Fatal("old reply changed reopened context")
}
})
t.Run("webhooks", func(t *testing.T) {
old := NewWebhooksPageModel(nil, "s")
old.Loading = false
old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
current := NewWebhooksPageModel(nil, "s")
current.Saving = true
current, _ = current.Update(webhooksLoadedMsg{Gen: old.Generation, Err: replyErr})
current, cmd := current.Update(webhooksSavedMsg{Gen: old.Generation, Err: replyErr, Notice: "obsolete"})
if !current.Loading || !current.Saving || current.Error != "" || current.Notice != "" || cmd != nil {
t.Fatal("old reply changed reopened webhooks")
}
})
}
}
func TestPageFocusedInputReceivesPasteAndCursorCommands(t *testing.T) {
for _, inputKind := range []string{"text", "secret"} {
t.Run(inputKind, func(t *testing.T) {
page := NewPageViewModel(nil, "s", "/notes")
page.Busy = false
page.Doc = &PageDocument{Actions: []PageAction{{Key: "e", Label: "edit", Input: inputKind}}}
styles := page.TextInput.Styles()
styles.Cursor.Blink = true
page.TextInput.SetStyles(styles)
page, _ = page.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
page, _ = page.Update(tea.PasteMsg{Content: "pasted value"})
if page.TextInput.Value() != "pasted value" {
t.Fatal("bracketed paste was lost")
}
if inputKind == "secret" && strings.Contains(page.View(), "pasted value") {
t.Fatal("pasted secret appeared in view")
}
page, cmd := page.Update(textinput.Blink())
if cmd == nil {
t.Fatal("input cursor command was dropped")
}
page, _ = page.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
page, _ = page.Update(tea.PasteMsg{Content: "ignored"})
if page.TextInput.Value() != "pasted value" {
t.Fatal("browse mode accepted paste")
}
})
}
}
func TestChatHistoryAndWindowCompletionsSurviveModal(t *testing.T) {
conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
session := &daemon.Session{ID: "s"}
chat := NewChatModel(session, daemon.NewChatClient(conn, "s"))
defer chat.Close()
chat.SetSize(80, 24)
chat.loadingOlder = true
model := AppModel{ActiveSession: session, Chat: chat, State: AppStateContextInspector}
updated, _ := model.Update(ChatOlderLoadedMsg{SessionID: "other", Generation: chat.Generation, Page: &daemon.HistoryPage{}})
model = updated.(AppModel)
if !model.Chat.loadingOlder {
t.Fatal("another session completed the active history request")
}
updated, _ = model.Update(ChatOlderLoadedMsg{SessionID: "s", Generation: chat.Generation - 1, Page: &daemon.HistoryPage{}})
model = updated.(AppModel)
if !model.Chat.loadingOlder {
t.Fatal("stale generation completed the active history request")
}
updated, _ = model.Update(ChatOlderLoadedMsg{SessionID: "s", Generation: chat.Generation, Page: &daemon.HistoryPage{Before: 10, More: true}})
model = updated.(AppModel)
if model.Chat.loadingOlder || !model.Chat.olderMore {
t.Fatal("modal lost history completion")
}
tokens := 123456
updated, _ = model.Update(ChatWindowMsg{SessionID: "s", Generation: chat.Generation, Model: "model", Tokens: &tokens})
model = updated.(AppModel)
if model.Chat.window == nil || *model.Chat.window != tokens {
t.Fatal("modal lost context window completion")
}
model.Chat.Follow = false
if model.Chat.loadOlder() == nil || !model.Chat.loadingOlder {
t.Fatal("paging stayed stuck after modal completion")
}
}
func TestDelayedForkCompletionCannotReplaceReopenedTree(t *testing.T) {
old := NewTreePickerModel(nil, "s")
old.Forking = true
old, cmd := old.Update(treeForkedMsg{Gen: old.Generation, Session: daemon.Session{ID: "fork"}})
if old.Forking || cmd == nil {
t.Fatal("current fork did not complete")
}
model := AppModel{State: AppStateTreePicker, TreePicker: NewTreePickerModel(nil, "s"), ActiveSession: &daemon.Session{ID: "s"}}
updated, next := model.Update(cmd())
model = updated.(AppModel)
if model.ActiveSession.ID != "s" || next != nil {
t.Fatal("discarded tree completion changed session")
}
}