okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: page_view docs on listFrame and listView, always-live filter, shared confirm, ctrl+r retry, gallery of every page state2ecf203

main
7.3 KiB162 linesraw
1// Screen replacement and modal races depend on command delivery order, which
2// daemon E2E cannot control. These tests drive the actual Bubble Tea models.
3package tui
4
5import (
6	"albedo/cli/internal/daemon"
7	"errors"
8	"strings"
9	"testing"
10
11	"charm.land/bubbles/v2/textinput"
12	tea "charm.land/bubbletea/v2"
13)
14
15func TestReopenedScreensRejectPreviousRequestReplies(t *testing.T) {
16	for _, replyErr := range []error{nil, errors.New("discarded request failed")} {
17		t.Run("page", func(t *testing.T) {
18			old := NewPageViewModel(nil, "s", "/notes")
19			old, _ = old.Update(pageLoadedMsg{Gen: old.Generation, Err: errors.New("retry")})
20			old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
21			current := NewPageViewModel(nil, "s", "/notes")
22			current, cmd := current.Update(pageLoadedMsg{Gen: old.Generation, Err: replyErr, Doc: &PageDocument{Title: "obsolete"}})
23			if !current.Busy || current.Doc != nil || current.Error != "" || cmd != nil {
24				t.Fatal("old load changed reopened page")
25			}
26			current, cmd = current.Update(pageActionExecutedMsg{Gen: old.Generation, Err: replyErr})
27			if !current.Busy || current.Error != "" || cmd != nil {
28				t.Fatal("old action completed reopened page")
29			}
30			current, _ = current.Update(pageLoadedMsg{Gen: current.Generation, Doc: &PageDocument{Title: "current"}})
31			if current.Busy || current.Doc.Title != "current" {
32				t.Fatal("current load was rejected")
33			}
34		})
35		t.Run("extensions", func(t *testing.T) {
36			old := NewExtensionPickerModel(nil, "s")
37			old, _ = old.Update(extensionsLoadedMsg{Gen: old.Generation, Err: errors.New("retry")})
38			old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
39			current := NewExtensionPickerModel(nil, "s")
40			current.Saving = true
41			current, _ = current.Update(extensionsLoadedMsg{Gen: old.Generation, Err: replyErr, Extensions: []ExtensionItem{{Name: "obsolete"}}})
42			current, cmd := current.Update(extensionToggledMsg{Gen: old.Generation, Err: replyErr})
43			if !current.Loading || !current.Saving || len(current.Extensions) != 0 || current.Error != "" || cmd != nil {
44				t.Fatal("old reply changed reopened extensions")
45			}
46		})
47		t.Run("tree", func(t *testing.T) {
48			old := NewTreePickerModel(nil, "s")
49			old, _ = old.loadPage(10)
50			current := NewTreePickerModel(nil, "s")
51			current.Forking = true
52			current, _ = current.Update(treeLoadedMsg{Gen: old.Generation, Err: replyErr})
53			current, cmd := current.Update(treeForkedMsg{Gen: old.Generation, Err: replyErr})
54			if !current.Loading || !current.Forking || current.Error != "" || current.ForkError != "" || cmd != nil {
55				t.Fatal("old reply changed reopened tree")
56			}
57		})
58		t.Run("context", func(t *testing.T) {
59			old := NewContextInspectorModel(nil, "s")
60			old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
61			current := NewContextInspectorModel(nil, "s")
62			current.Detail = &ContextDetail{Section: daemon.ContextSection{ID: "history"}}
63			current, _ = current.Update(contextSnapshotLoadedMsg{Gen: old.Generation, Err: replyErr, Snapshot: &daemon.ContextSnapshot{State: "ready"}})
64			current, _ = current.Update(contextPageLoadedMsg{Gen: old.Generation, Err: replyErr, SectionID: "history", Data: &daemon.ContextPage{Content: "obsolete"}})
65			if !current.Loading || current.Snapshot != nil || current.Error != "" || current.Detail.Value != nil || current.Detail.Error != "" {
66				t.Fatal("old reply changed reopened context")
67			}
68		})
69		t.Run("webhooks", func(t *testing.T) {
70			old := NewWebhooksPageModel(nil, "s")
71			old.Loading = false
72			old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
73			current := NewWebhooksPageModel(nil, "s")
74			current.Saving = true
75			current, _ = current.Update(webhooksLoadedMsg{Gen: old.Generation, Err: replyErr})
76			current, cmd := current.Update(webhooksSavedMsg{Gen: old.Generation, Err: replyErr, Notice: "obsolete"})
77			if !current.Loading || !current.Saving || current.Error != "" || current.Notice != "" || cmd != nil {
78				t.Fatal("old reply changed reopened webhooks")
79			}
80		})
81	}
82}
83
84func TestPageFocusedInputReceivesPasteAndCursorCommands(t *testing.T) {
85	for _, inputKind := range []string{"text", "secret"} {
86		t.Run(inputKind, func(t *testing.T) {
87			page := NewPageViewModel(nil, "s", "/notes")
88			page.Busy = false
89			page.setDoc(&PageDocument{Actions: []PageAction{{Key: "ctrl+e", Label: "edit", Fields: []daemon.FormField{{Name: "value", Label: "value", Type: inputKind}}}}})
90			styles := page.TextInput.Styles()
91			styles.Cursor.Blink = true
92			page.TextInput.SetStyles(styles)
93			page, _ = page.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})
94			page, _ = page.Update(tea.PasteMsg{Content: "pasted value"})
95			if page.TextInput.Value() != "pasted value" {
96				t.Fatal("bracketed paste was lost")
97			}
98			if inputKind == "secret" && strings.Contains(page.View(), "pasted value") {
99				t.Fatal("pasted secret appeared in view")
100			}
101			page, cmd := page.Update(textinput.Blink())
102			if cmd == nil {
103				t.Fatal("input cursor command was dropped")
104			}
105			page, _ = page.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
106			page, _ = page.Update(tea.PasteMsg{Content: "ignored"})
107			if page.TextInput.Value() != "" {
108				t.Fatal("dismissed form retained input or browse mode accepted paste")
109			}
110		})
111	}
112}
113
114func TestChatHistoryAndWindowCompletionsSurviveModal(t *testing.T) {
115	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
116	session := &daemon.Session{ID: "s"}
117	chat := NewChatModel(session, daemon.NewChatClient(conn, "s"))
118	defer chat.Close()
119	chat.SetSize(80, 24)
120	chat.loadingOlder = true
121	model := &AppModel{ActiveSession: session, Chat: chat, State: AppStateContextInspector}
122	updated, _ := model.Update(ChatOlderLoadedMsg{SessionID: "other", Generation: chat.Generation, Page: &daemon.HistoryPage{}})
123	model = updated.(*AppModel)
124	if !model.Chat.loadingOlder {
125		t.Fatal("another session completed the active history request")
126	}
127	updated, _ = model.Update(ChatOlderLoadedMsg{SessionID: "s", Generation: chat.Generation - 1, Page: &daemon.HistoryPage{}})
128	model = updated.(*AppModel)
129	if !model.Chat.loadingOlder {
130		t.Fatal("stale generation completed the active history request")
131	}
132	updated, _ = model.Update(ChatOlderLoadedMsg{SessionID: "s", Generation: chat.Generation, Page: &daemon.HistoryPage{Before: 10, More: true}})
133	model = updated.(*AppModel)
134	if model.Chat.loadingOlder || !model.Chat.olderMore {
135		t.Fatal("modal lost history completion")
136	}
137	tokens := 123456
138	updated, _ = model.Update(ChatWindowMsg{SessionID: "s", Generation: chat.Generation, Model: "model", Tokens: &tokens})
139	model = updated.(*AppModel)
140	if model.Chat.window == nil || *model.Chat.window != tokens {
141		t.Fatal("modal lost context window completion")
142	}
143	model.Chat.Follow = false
144	if model.Chat.loadOlder() == nil || !model.Chat.loadingOlder {
145		t.Fatal("paging stayed stuck after modal completion")
146	}
147}
148
149func TestDelayedForkCompletionCannotReplaceReopenedTree(t *testing.T) {
150	old := NewTreePickerModel(nil, "s")
151	old.Forking = true
152	old, cmd := old.Update(treeForkedMsg{Gen: old.Generation, Session: daemon.Session{ID: "fork"}})
153	if old.Forking || cmd == nil {
154		t.Fatal("current fork did not complete")
155	}
156	model := &AppModel{State: AppStateTreePicker, TreePicker: NewTreePickerModel(nil, "s"), ActiveSession: &daemon.Session{ID: "s"}}
157	updated, next := model.Update(cmd())
158	model = updated.(*AppModel)
159	if model.ActiveSession.ID != "s" || next != nil {
160		t.Fatal("discarded tree completion changed session")
161	}
162}