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
4.0 KiB113 linesraw
1// Where the detail goes is a pure rendering decision: the daemon sends the
2// same document at every size, so no e2e through the daemon can catch a
3// wrong split threshold, a pane that overflows the terminal, or detail
4// dropped from parsing.
5package tui
6
7import (
8	"strings"
9	"testing"
10
11	tea "charm.land/bubbletea/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15func detailFixture() *PageDocument {
16	return &PageDocument{
17		Title:   "paperclips",
18		Summary: "1 open · 1 resolved",
19		Empty:   "nothing vented yet",
20		Rows: []PageRow{{
21			ID:     "1",
22			Text:   "ruff not on PATH",
23			Badge:  "open",
24			Tone:   ToneWarning,
25			Detail: "ruff is missing from PATH; the binary lives in pre-commit's env.\n\nsuggestion: document the canonical invocation",
26		}, {
27			ID:    "2",
28			Text:  "stale binary",
29			Badge: "resolved",
30			Tone:  ToneMuted,
31		}},
32	}
33}
34
35func pageAt(width, height int, doc *PageDocument) PageViewModel {
36	m := NewPageViewModel(nil, "s", "/paperclips")
37	m.SetSize(width, height)
38	m.setDoc(doc)
39	m.Busy = false
40	return m
41}
42
43func TestPageFitsEveryTerminal(t *testing.T) {
44	for _, size := range [][2]int{{170, 40}, {120, 30}, {96, 14}, {80, 24}, {60, 12}, {40, 8}, {20, 4}} {
45		view := pageAt(size[0], size[1], detailFixture()).View()
46		lines := strings.Split(view, "\n")
47		if len(lines) > size[1] {
48			t.Fatalf("%dx%d: %d lines:\n%s", size[0], size[1], len(lines), view)
49		}
50		for _, line := range lines {
51			if w := ansi.StringWidth(line); w > size[0] {
52				t.Fatalf("%dx%d: a line is %d wide:\n%s", size[0], size[1], w, view)
53			}
54		}
55	}
56}
57
58func TestWidePageShowsDetailBesideTheList(t *testing.T) {
59	view := ansi.Strip(pageAt(120, 30, detailFixture()).View())
60	for _, want := range []string{"ruff not on PATH", "│", "suggestion: document", "open · #1"} {
61		if !strings.Contains(view, want) {
62			t.Fatalf("wide view lost %q:\n%s", want, view)
63		}
64	}
65}
66
67func TestNarrowPageStacksTheDetailUnderTheList(t *testing.T) {
68	view := ansi.Strip(pageAt(80, 24, detailFixture()).View())
69	if strings.Contains(view, "│") {
70		t.Fatalf("a narrow view should not split side by side:\n%s", view)
71	}
72	list, detail := strings.Index(view, "stale binary"), strings.Index(view, "pre-commit's env")
73	if list < 0 || detail < list {
74		t.Fatalf("a narrow view should show the detail under the list:\n%s", view)
75	}
76}
77
78func TestRowsWithoutDetailKeepTheFullWidth(t *testing.T) {
79	doc := detailFixture()
80	doc.Rows[0].Detail = ""
81	view := ansi.Strip(pageAt(120, 30, doc).View())
82	if strings.Contains(view, "│") || strings.Contains(view, "open · #1") {
83		t.Fatalf("a page without details should keep the list alone:\n%s", view)
84	}
85}
86
87// Actions are chords and never bare letters: a letter typed into a page
88// filters its rows, and only a chord asks for a question or runs one. E2E
89// cannot see which keys reach the filter, so these drive the model directly.
90func TestPageBareLetterFiltersInsteadOfActing(t *testing.T) {
91	doc := detailFixture()
92	doc.Actions = []PageAction{{ID: "delete", Key: "ctrl+d", Label: "delete", Row: true, Confirm: true, Confirmation: "Delete this item?"}}
93	m := pageAt(120, 30, doc)
94	m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
95	if m.Mode != modeBrowse || m.CurrentAction != nil || m.listView.input.Value() != "d" {
96		t.Fatalf("a bare letter should only filter: mode %v, action %v, filter %q", m.Mode, m.CurrentAction, m.listView.input.Value())
97	}
98	m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
99	if m.Mode != modeConfirm || m.CurrentAction == nil || !m.Confirming() {
100		t.Fatal("the chord should ask before deleting")
101	}
102	if !strings.Contains(ansi.Strip(m.View()), "Delete this item?") {
103		t.Fatalf("the question should read in the footer:\n%s", ansi.Strip(m.View()))
104	}
105	m, _ = m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
106	if m.Mode != modeConfirm || m.listView.input.Value() != "d" {
107		t.Fatal("a stray key should neither answer nor reach the filter while asking")
108	}
109	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
110	if m.Mode != modeBrowse || m.CurrentAction != nil || m.Confirming() {
111		t.Fatal("esc should drop the question")
112	}
113}