model_test.go (17863B)
1 package tui 2 3 import ( 4 "context" 5 "fmt" 6 "strings" 7 "testing" 8 "time" 9 10 "git.ryansepassi.com/git/gotextlog.git/internal/config" 11 "git.ryansepassi.com/git/gotextlog.git/internal/sshkey" 12 "git.ryansepassi.com/git/gotextlog.git/internal/textlog" 13 "github.com/charmbracelet/bubbles/spinner" 14 tea "github.com/charmbracelet/bubbletea" 15 "github.com/charmbracelet/lipgloss" 16 ) 17 18 type acceptanceBackend struct{ calls []Operation } 19 20 func (b *acceptanceBackend) Execute(_ context.Context, op Operation) (Result, error) { 21 b.calls = append(b.calls, op) 22 root := textlog.Post{ID: 1, Body: "first note", CreatedAt: time.Now().Add(-time.Minute), 23 Author: textlog.UserReference{Handle: "alice"}, ReplyCount: 1} 24 switch op.Kind { 25 case OpFeed: 26 return Result{Posts: []textlog.Post{root}}, nil 27 case OpThread: 28 return Result{Post: &root, Replies: []textlog.Reply{{Post: textlog.Post{ID: 2, ParentID: intPtr(1), 29 Body: "a reply", CreatedAt: time.Now(), Author: textlog.UserReference{Handle: "bob"}}, Depth: 1}}}, nil 30 case OpCreatePost: 31 created := textlog.Post{ID: 9, ParentID: op.ParentID, Body: op.Text, CreatedAt: time.Now(), 32 Author: textlog.UserReference{Handle: "me"}} 33 return Result{Post: &created}, nil 34 case OpMe: 35 return Result{Me: &textlog.CurrentUser{Handle: "me", CanPost: true}}, nil 36 } 37 return Result{}, nil 38 } 39 40 func (b *acceptanceBackend) Firehose(context.Context) (<-chan textlog.Post, <-chan error) { 41 posts := make(chan textlog.Post) 42 errs := make(chan error) 43 close(posts) 44 close(errs) 45 return posts, errs 46 } 47 48 func intPtr(value int) *int { return &value } 49 50 func TestDefaultFeedIsLatest(t *testing.T) { 51 backend := &acceptanceBackend{} 52 model := New(backend, config.Config{BaseURL: "https://example.test"}, Options{}) 53 model = settle(t, model, model.Init()) 54 55 if len(backend.calls) != 1 || backend.calls[0].Kind != OpFeed || backend.calls[0].Feed != string(config.FeedLatest) { 56 t.Fatalf("startup calls = %#v, want latest feed", backend.calls) 57 } 58 if model.current().feed != string(config.FeedLatest) { 59 t.Fatalf("startup feed = %q, want latest", model.current().feed) 60 } 61 } 62 63 func settle(t *testing.T, model Model, cmd tea.Cmd) Model { 64 t.Helper() 65 if cmd == nil { 66 return model 67 } 68 msg := cmd() 69 if _, ok := msg.(spinner.TickMsg); ok { 70 return model 71 } 72 if batch, ok := msg.(tea.BatchMsg); ok { 73 for _, child := range batch { 74 model = settle(t, model, child) 75 } 76 return model 77 } 78 updated, next := model.Update(msg) 79 model = updated.(Model) 80 return settle(t, model, next) 81 } 82 83 func key(model Model, value string) (Model, tea.Cmd) { 84 var msg tea.KeyMsg 85 switch value { 86 case "enter": 87 msg = tea.KeyMsg{Type: tea.KeyEnter} 88 case "ctrl+s": 89 msg = tea.KeyMsg{Type: tea.KeyCtrlS} 90 default: 91 msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)} 92 } 93 updated, cmd := model.Update(msg) 94 return updated.(Model), cmd 95 } 96 97 func TestCompleteFeedThreadReplyFlow(t *testing.T) { 98 backend := &acceptanceBackend{} 99 model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{}) 100 model = settle(t, model, model.Init()) 101 model, cmd := key(model, "enter") 102 model = settle(t, model, cmd) 103 if model.current().kind != screenPost { 104 t.Fatalf("open feed item: %#v", model.current()) 105 } 106 if !strings.Contains(model.View(), "a reply") { 107 t.Fatalf("thread did not render reply:\n%s", model.View()) 108 } 109 model, _ = key(model, "r") 110 if model.current().kind != screenCompose { 111 t.Fatalf("reply screen: %#v", model.current()) 112 } 113 model, _ = key(model, "reply from tui") 114 model, _ = key(model, "ctrl+s") 115 if !model.current().preview { 116 t.Fatal("Ctrl+S did not open preview") 117 } 118 model, cmd = key(model, "enter") 119 model = settle(t, model, cmd) 120 if model.current().kind != screenPost || model.current().id != 9 { 121 t.Fatalf("publish screen: %#v", model.current()) 122 } 123 if model.status != "Note posted" { 124 t.Fatalf("status=%q", model.status) 125 } 126 model, _ = key(model, "q") 127 if model.current().kind == screenPost && model.current().id == 9 { 128 t.Fatal("q did not navigate back") 129 } 130 if len(backend.calls) < 4 { 131 t.Fatalf("calls=%v", backend.calls) 132 } 133 } 134 func TestReplyEditorAcceptsSpace(t *testing.T) { 135 model := New(&acceptanceBackend{}, config.Config{}, Options{}) 136 parent := textlog.Post{ID: 1, Author: textlog.UserReference{Handle: "alice"}} 137 model.openCompose(&parent, nil) 138 139 model, _ = key(model, "hello") 140 updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace}) 141 model = updated.(Model) 142 model, _ = key(model, "world") 143 144 if got := model.current().input; got != "hello world" { 145 t.Fatalf("reply editor input = %q, want %q", got, "hello world") 146 } 147 } 148 149 func TestTopNavigationRendersWithoutNoteMetadata(t *testing.T) { 150 backend := &acceptanceBackend{} 151 model := New(backend, config.Config{NoColor: true}, Options{}) 152 s := model.newScreen(screenPost) 153 s.posts = []textlog.Post{{ID: 1, Body: "↑ top"}} 154 model.stack = []*screen{s} 155 156 view := model.View() 157 if !strings.Contains(view, "↑ top") { 158 t.Fatalf("top navigation missing:\n%s", view) 159 } 160 for _, metadata := range []string{"@ now", "0 replies"} { 161 if strings.Contains(view, metadata) { 162 t.Fatalf("top navigation includes %q metadata:\n%s", metadata, view) 163 } 164 } 165 166 model, cmd := key(model, "enter") 167 model = settle(t, model, cmd) 168 if len(backend.calls) != 1 || backend.calls[0].Kind != OpThread || backend.calls[0].ID != 1 { 169 t.Fatalf("top navigation calls = %#v", backend.calls) 170 } 171 } 172 173 func TestReplyFocusShowsDepthFirstFoldableWholeThread(t *testing.T) { 174 now := time.Now() 175 root := textlog.Post{ID: 1, Body: "root", CreatedAt: now, ReplyCount: 2, Author: textlog.UserReference{Handle: "root"}} 176 focused := textlog.Post{ID: 3, TopID: intPtr(1), ParentID: intPtr(2), Body: "focused", CreatedAt: now.Add(2 * time.Minute), ReplyCount: 1, Author: textlog.UserReference{Handle: "focused"}} 177 result := Result{Post: &focused, Root: &root, Replies: []textlog.Reply{ 178 {Post: textlog.Post{ID: 5, TopID: intPtr(1), ParentID: intPtr(3), Body: "newest", CreatedAt: now.Add(4 * time.Minute), Author: textlog.UserReference{Handle: "five"}}, Depth: 3}, 179 {Post: textlog.Post{ID: 4, TopID: intPtr(1), ParentID: intPtr(1), Body: "later sibling", CreatedAt: now.Add(3 * time.Minute), Author: textlog.UserReference{Handle: "four"}}, Depth: 1}, 180 {Post: focused, Depth: 2}, 181 {Post: textlog.Post{ID: 2, TopID: intPtr(1), ParentID: intPtr(1), Body: "parent", CreatedAt: now.Add(time.Minute), Author: textlog.UserReference{Handle: "two"}}, Depth: 1}, 182 }} 183 184 model := New(&acceptanceBackend{}, config.Config{Theme: config.ThemeDark}, Options{}) 185 s := model.newScreen(screenPost) 186 s.id = focused.ID 187 model.stack = []*screen{s} 188 updated, _ := model.applyLoaded(loadedMsg{serial: s.serial, op: Operation{Kind: OpThread, ID: focused.ID}, result: result}) 189 model = updated.(Model) 190 191 assertPostIDs(t, model.current().posts, 1, 2, 3, 5, 4) 192 if model.current().selected != 2 { 193 t.Fatalf("focused selection = %d, want 2", model.current().selected) 194 } 195 196 model, _ = key(model, " ") 197 assertPostIDs(t, model.current().posts, 1, 2, 3, 4) 198 if !strings.Contains(model.View(), "1 replies folded") { 199 t.Fatalf("folded thread did not show its state:\n%s", model.View()) 200 } 201 model, _ = key(model, " ") 202 assertPostIDs(t, model.current().posts, 1, 2, 3, 5, 4) 203 204 model, _ = key(model, "g") 205 model, _ = key(model, " ") 206 assertPostIDs(t, model.current().posts, 1) 207 model, _ = key(model, " ") 208 assertPostIDs(t, model.current().posts, 1, 2, 3, 5, 4) 209 } 210 211 func assertPostIDs(t *testing.T, posts []textlog.Post, want ...int) { 212 t.Helper() 213 if len(posts) != len(want) { 214 t.Fatalf("post count = %d, want %d: %#v", len(posts), len(want), posts) 215 } 216 for i := range want { 217 if posts[i].ID != want[i] { 218 t.Fatalf("post[%d].ID = %d, want %d", i, posts[i].ID, want[i]) 219 } 220 } 221 } 222 223 func TestFollowingTagNavigationAndSettingsMenu(t *testing.T) { 224 backend := &acceptanceBackend{} 225 model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{}) 226 profile := model.newScreen(screenProfile) 227 profile.activeTab = 3 228 profile.followingTags = []textlog.TagReference{{Tag: "go"}} 229 model.stack = append(model.stack, profile) 230 model, cmd := key(model, "enter") 231 model = settle(t, model, cmd) 232 if model.current().kind != screenTag || model.current().tag != "go" { 233 t.Fatalf("following tag did not open: %#v", model.current()) 234 } 235 236 settings := model.newScreen(screenSettings) 237 model.stack = append(model.stack, settings) 238 model, _ = key(model, "j") 239 model, _ = key(model, "enter") 240 if model.current().phase != "url" { 241 t.Fatalf("settings selection did not open URL editor: %#v", model.current()) 242 } 243 } 244 245 func TestLoginCodeRequiresDigits(t *testing.T) { 246 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark}, Options{}) 247 login := model.newScreen(screenLogin) 248 login.phase, login.query, login.input = "code", "me@example.test", "abcdef" 249 model.stack = append(model.stack, login) 250 model, cmd := key(model, "enter") 251 if cmd != nil || model.status != "Enter the six-digit code" { 252 t.Fatalf("accepted non-numeric login code: status=%q cmd=%v", model.status, cmd != nil) 253 } 254 } 255 256 type configurableAcceptanceBackend struct { 257 acceptanceBackend 258 configured []config.Config 259 } 260 261 func (b *configurableAcceptanceBackend) Configure(cfg config.Config) { 262 b.configured = append(b.configured, cfg) 263 } 264 265 func TestPersistsFeedAndAppliesSavedConfiguration(t *testing.T) { 266 backend := &configurableAcceptanceBackend{} 267 var saved []config.Config 268 model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{ 269 SaveConfig: func(cfg config.Config) error { 270 saved = append(saved, cfg) 271 return nil 272 }, 273 }) 274 model = settle(t, model, model.Init()) 275 saved = nil 276 backend.configured = nil 277 model, cmd := key(model, "4") 278 model = settle(t, model, cmd) 279 if len(saved) != 1 || saved[0].LastTab != config.FeedLatest { 280 t.Fatalf("last feed was not persisted: %#v", saved) 281 } 282 if len(backend.configured) != 1 || backend.configured[0].LastTab != config.FeedLatest { 283 t.Fatalf("saved config was not applied to backend: %#v", backend.configured) 284 } 285 } 286 func TestFocusedRichTextTargetIsVisible(t *testing.T) { 287 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark}, Options{}) 288 model.current().loading = false 289 model.current().posts = []textlog.Post{{ID: 1, Body: "visit https://example.test", Author: textlog.UserReference{Handle: "alice"}}} 290 model, _ = key(model, "tab") 291 if model.current().target != 0 { 292 t.Fatalf("focused target was not selected: target=%d", model.current().target) 293 } 294 } 295 func TestPausedLiveScreenIgnoresStreamClosure(t *testing.T) { 296 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark}, Options{}) 297 live := model.newScreen(screenLive) 298 live.paused = true 299 live.streamState = "paused" 300 model.stack = append(model.stack, live) 301 updated, cmd := model.Update(liveMsg{serial: live.serial, closed: true}) 302 model = updated.(Model) 303 if cmd != nil || model.current().streamState != "paused" { 304 t.Fatalf("paused stream reconnected: state=%q cmd=%v", model.current().streamState, cmd != nil) 305 } 306 } 307 func TestPostListUsesDenseRowsAndShowsActivityContext(t *testing.T) { 308 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) 309 model.width, model.height = 80, 24 310 s := model.current() 311 s.loading = false 312 s.entries = []ActivityEntry{{Post: textlog.Post{ID: 1, Body: "hello", Author: textlog.UserReference{Handle: "alice"}}, Context: "mentioned you"}} 313 s.posts = []textlog.Post{s.entries[0].Post} 314 view := model.View() 315 if strings.Contains(view, "╭") || !strings.Contains(view, "┃") { 316 t.Fatalf("post list is not using a dense selected row:\n%s", view) 317 } 318 if !strings.Contains(view, "mentioned you") { 319 t.Fatalf("activity context is missing:\n%s", view) 320 } 321 } 322 func TestLatestReplyRendersItsParentAboveIt(t *testing.T) { 323 parent := textlog.Post{ID: 10, Body: "parent body", CreatedAt: time.Now().Add(-time.Minute), Author: textlog.UserReference{Handle: "parent"}} 324 reply := textlog.Post{ID: 11, ParentID: intPtr(parent.ID), Parent: &parent, Body: "reply body", CreatedAt: time.Now(), Author: textlog.UserReference{Handle: "child"}} 325 model := New(&acceptanceBackend{}, config.Config{NoColor: true}, Options{}) 326 model.width, model.height = 80, 40 327 s := model.current() 328 s.loading = false 329 s.feed = string(config.FeedLatest) 330 s.posts = []textlog.Post{reply} 331 332 view := model.View() 333 parentAt, replyAt := strings.Index(view, "parent body"), strings.Index(view, "reply body") 334 if parentAt < 0 || replyAt < 0 || parentAt >= replyAt { 335 t.Fatalf("latest reply did not render parent above reply:\n%s", view) 336 } 337 if !strings.Contains(view[:replyAt], "· parent") || !strings.Contains(view[parentAt:replyAt], "│") { 338 t.Fatalf("latest reply did not show parent context and reply indentation:\n%s", view) 339 } 340 } 341 342 func TestPostListFillsAvailableHeight(t *testing.T) { 343 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) 344 model.width, model.height = 120, 40 345 s := model.current() 346 s.loading = false 347 for i := 1; i <= 15; i++ { 348 s.posts = append(s.posts, textlog.Post{ID: i, Body: "body", Author: textlog.UserReference{Handle: fmt.Sprintf("user-%02d", i)}}) 349 } 350 if view := model.View(); !strings.Contains(view, "@user-15") { 351 t.Fatalf("list did not fill the viewport:\n%s", view) 352 } 353 } 354 func TestEditorUsesOneCompactHintAndStableField(t *testing.T) { 355 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) 356 model.width, model.height = 80, 24 357 s := model.newScreen(screenSearch) 358 s.phase = "input" 359 model.stack = append(model.stack, s) 360 view := model.View() 361 if strings.Count(view, "Enter continue") != 1 || strings.Contains(view, "╭") || !strings.Contains(view, "┃") { 362 t.Fatalf("editor is not compact and coherent:\n%s", view) 363 } 364 } 365 func TestProfileHeaderDoesNotRepeatScreenIdentity(t *testing.T) { 366 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) 367 profile := model.newScreen(screenProfile) 368 profile.handle = "alice" 369 profile.profile = &textlog.Profile{Handle: "alice", Bio: "short bio"} 370 model.stack = append(model.stack, profile) 371 if view := model.View(); strings.Count(view, "@alice") != 1 { 372 t.Fatalf("profile identity is repeated:\n%s", view) 373 } 374 } 375 func TestReferenceListKeepsSelectionVisible(t *testing.T) { 376 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) 377 model.width, model.height = 65, 30 378 s := model.newScreen(screenProfile) 379 s.handle = "alice" 380 s.profile = &textlog.Profile{Handle: "alice"} 381 s.activeTab, s.selected = 2, 30 382 for i := 0; i < 40; i++ { 383 s.users = append(s.users, textlog.UserReference{Handle: fmt.Sprintf("user-%02d", i)}) 384 } 385 model.stack = append(model.stack, s) 386 if view := model.View(); !strings.Contains(view, "› @user-30") { 387 t.Fatalf("selected reference is outside the viewport:\n%s", view) 388 } 389 } 390 func TestAutomaticThemeUsesTerminalPalette(t *testing.T) { 391 p := colors(config.ThemeAuto, false) 392 if p.ink != "" || p.accent != "2" || p.muted != "8" { 393 t.Fatalf("automatic palette uses fixed colors: %#v", p) 394 } 395 } 396 func TestAuthenticatedHeaderStaysOnOneLine(t *testing.T) { 397 model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeAuto, Token: "token"}, Options{}) 398 model.width = 65 399 model.me = &textlog.CurrentUser{Handle: "longusername"} 400 if height := lipgloss.Height(model.header(model.current(), colors(config.ThemeAuto, false))); height != 1 { 401 t.Fatalf("authenticated header height = %d, want 1", height) 402 } 403 } 404 405 type keyLoginBackend struct { 406 acceptanceBackend 407 keys []sshkey.Key 408 } 409 410 func (b *keyLoginBackend) Execute(ctx context.Context, op Operation) (Result, error) { 411 b.calls = append(b.calls, op) 412 switch op.Kind { 413 case OpListKeys: 414 return Result{Keys: b.keys}, nil 415 case OpKeyLogin: 416 user := textlog.CurrentUser{Handle: op.Handle, CanPost: true} 417 session := textlog.Session{Token: "key-token", User: user} 418 return Result{Session: &session, Me: &user}, nil 419 default: 420 return b.acceptanceBackend.Execute(ctx, op) 421 } 422 } 423 424 func TestKeyLoginModeSelectsKeyRegistersAndSavesSession(t *testing.T) { 425 backend := &keyLoginBackend{keys: []sshkey.Key{ 426 {Path: "/home/me/.ssh/first.pub", PublicKey: "ssh-ed25519 first", Fingerprint: "SHA256:first"}, 427 {Path: "/home/me/.ssh/second.pub", PublicKey: "ssh-ed25519 second", Fingerprint: "SHA256:second"}, 428 }} 429 var saved config.Config 430 model := New(backend, config.Config{BaseURL: "https://example.test"}, Options{SaveConfig: func(cfg config.Config) error { 431 saved = cfg 432 return nil 433 }}) 434 model.openLogin() 435 436 model, cmd := key(model, "tab") 437 model = settle(t, model, cmd) 438 if model.current().phase != "key" || len(model.current().keys) != 2 { 439 t.Fatalf("key mode did not list keys: %#v", model.current()) 440 } 441 model, _ = key(model, "j") 442 model, _ = key(model, "enter") 443 if model.current().phase != "handle" { 444 t.Fatalf("key selection did not request handle: %#v", model.current()) 445 } 446 model, _ = key(model, "alice") 447 model, cmd = key(model, "enter") 448 model = settle(t, model, cmd) 449 450 if saved.Token != "key-token" || model.me == nil || model.me.Handle != "alice" || model.current().kind != screenFeed { 451 t.Fatalf("key login did not finish: saved=%#v me=%#v screen=%#v", saved, model.me, model.current()) 452 } 453 var login Operation 454 for _, op := range backend.calls { 455 if op.Kind == OpKeyLogin { 456 login = op 457 } 458 } 459 if login.Handle != "alice" || login.Key.Fingerprint != "SHA256:second" { 460 t.Fatalf("login operation=%#v", login) 461 } 462 }