gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

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 }