model_test.go RawHistoryFolder

1package notices
2
3import (
4	"fmt"
5	"strings"
6	"testing"
7	"time"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11)
12
13func testStyles() RenderStyles {
14	selection := renderer.DefaultStyle()
15	selection.Inverse = true
16	base := renderer.DefaultStyle()
17	return RenderStyles{Background: base, Base: base, Selection: selection, Text: base, SelectionText: selection, Muted: base, SelectionMuted: selection}
18}
19
20// rowText flattens one rendered row back to text, skipping wide-rune
21// continuation cells, so assertions can check substrings.
22func rowText(f renderer.Frame, y int) string {
23	var b strings.Builder
24	for _, c := range f.Row(y) {
25		if c.Continuation {
26			continue
27		}
28		if c.Rune == 0 {
29			b.WriteRune(' ')
30			continue
31		}
32		b.WriteRune(c.Rune)
33	}
34	return b.String()
35}
36
37func TestNewSelectsNewestFirstEntry(t *testing.T) {
38	now := time.Unix(1000, 0)
39	entries := []domain.Notification{
40		{Code: domain.NoticePaneSpawn, Message: "newest", Time: now},
41		{Code: domain.NoticeTabSpawn, Message: "oldest", Time: now.Add(-time.Hour)},
42	}
43	m := New(entries, now)
44	got, ok := m.Selected()
45	if !ok || got.Message != "newest" {
46		t.Fatalf("Selected() = %+v, %v, want newest, true", got, ok)
47	}
48}
49
50func TestNavigationClampsAtBothEnds(t *testing.T) {
51	tests := []struct {
52		name  string
53		moves func(m *Model)
54		want  string
55	}{
56		{"up from top stays at top", func(m *Model) { m.Up(); m.Up() }, "a"},
57		{"down past bottom stays at bottom", func(m *Model) { m.Down(); m.Down(); m.Down() }, "c"},
58		{"down then up returns to top", func(m *Model) { m.Down(); m.Down(); m.Up(); m.Up() }, "a"},
59	}
60	for _, tt := range tests {
61		t.Run(tt.name, func(t *testing.T) {
62			now := time.Unix(1000, 0)
63			m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}, {Message: "c", Time: now}}, now)
64			tt.moves(m)
65			got, ok := m.Selected()
66			if !ok || got.Message != tt.want {
67				t.Fatalf("Selected() = %q, %v, want %q, true", got.Message, ok, tt.want)
68			}
69		})
70	}
71}
72
73func TestSelectedAndNavigationOnEmptyHistory(t *testing.T) {
74	m := New(nil, time.Unix(1000, 0))
75	if _, ok := m.Selected(); ok {
76		t.Fatal("Selected() ok = true on empty history, want false")
77	}
78	m.Up()
79	m.Down()
80	if _, ok := m.Selected(); ok {
81		t.Fatal("Selected() ok = true after navigating empty history, want false")
82	}
83}
84
85func TestRelativeTime(t *testing.T) {
86	now := time.Unix(10000, 0)
87	tests := []struct {
88		name string
89		at   time.Time
90		want string
91	}{
92		{"seconds ago", now.Add(-30 * time.Second), "just now"},
93		{"two minutes ago", now.Add(-2 * time.Minute), "2m ago"},
94		{"fifty nine minutes ago", now.Add(-59 * time.Minute), "59m ago"},
95		{"two hours ago", now.Add(-2 * time.Hour), "2h ago"},
96		{"two days ago", now.Add(-48 * time.Hour), "2d ago"},
97		{"future clock skew", now.Add(time.Minute), "just now"},
98	}
99	for _, tt := range tests {
100		t.Run(tt.name, func(t *testing.T) {
101			if got := relativeTime(now, tt.at); got != tt.want {
102				t.Errorf("relativeTime(now, %v) = %q, want %q", tt.at, got, tt.want)
103			}
104		})
105	}
106}
107
108func TestRenderRowContainsSeverityCodeSlugAndCount(t *testing.T) {
109	now := time.Unix(10000, 0)
110	entries := []domain.Notification{
111		{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Count: 3, Time: now.Add(-2 * time.Minute)},
112	}
113	m := New(entries, now)
114	frame := m.Render(domain.Size{Cols: 60, Rows: 5}, testStyles())
115	row := rowText(frame, 0)
116	for _, want := range []string{"[E]", "2m ago", "pane-spawn", "couldn't open pane", "×3"} {
117		if !strings.Contains(row, want) {
118			t.Errorf("row %q does not contain %q", row, want)
119		}
120	}
121}
122
123func TestRenderSeverityLetterPerSeverity(t *testing.T) {
124	now := time.Unix(10000, 0)
125	tests := []struct {
126		sev  domain.NoticeSeverity
127		want string
128	}{
129		{domain.NoticeInfo, "[I]"},
130		{domain.NoticeWarn, "[W]"},
131		{domain.NoticeError, "[E]"},
132	}
133	for _, tt := range tests {
134		m := New([]domain.Notification{{Severity: tt.sev, Message: "m", Time: now}}, now)
135		frame := m.Render(domain.Size{Cols: 40, Rows: 3}, testStyles())
136		row := rowText(frame, 0)
137		if !strings.Contains(row, tt.want) {
138			t.Errorf("severity %v: row %q does not contain %q", tt.sev, row, tt.want)
139		}
140	}
141}
142
143func TestRenderEmptyHistoryShowsPlaceholderNotBlank(t *testing.T) {
144	m := New(nil, time.Unix(0, 0))
145	frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles())
146	blank := true
147	for y := 0; y < frame.Height; y++ {
148		if strings.TrimSpace(rowText(frame, y)) != "" {
149			blank = false
150			break
151		}
152	}
153	if blank {
154		t.Fatal("Render() on empty history drew nothing, want a placeholder")
155	}
156}
157
158func TestRenderDegenerateDimensionsDoesNotPanic(t *testing.T) {
159	m := New([]domain.Notification{{Message: "x", Time: time.Unix(0, 0)}}, time.Unix(0, 0))
160	tests := []struct {
161		size       domain.Size
162		wantWidth  int
163		wantHeight int
164	}{
165		// Render clamps each negative dimension to 0 independently via
166		// max(inner.Cols, 0) / max(inner.Rows, 0); it never clamps the pair
167		// together, so a negative Cols with a valid Rows still yields a
168		// zero-width, full-height frame (and vice versa).
169		{domain.Size{Cols: 0, Rows: 0}, 0, 0},
170		{domain.Size{Cols: -1, Rows: 5}, 0, 5},
171		{domain.Size{Cols: 5, Rows: -1}, 5, 0},
172		{domain.Size{Cols: 0, Rows: 5}, 0, 5},
173		{domain.Size{Cols: 1, Rows: 1}, 1, 1},
174	}
175	for _, tt := range tests {
176		frame := m.Render(tt.size, testStyles())
177		if frame.Width != tt.wantWidth || frame.Height != tt.wantHeight {
178			t.Fatalf("Render(%+v) = frame{Width: %d, Height: %d}, want {Width: %d, Height: %d}", tt.size, frame.Width, frame.Height, tt.wantWidth, tt.wantHeight)
179		}
180	}
181}
182
183func TestRenderScrollsToKeepSelectionVisible(t *testing.T) {
184	now := time.Unix(10000, 0)
185	entries := make([]domain.Notification, 200)
186	for i := range entries {
187		entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now}
188	}
189	m := New(entries, now)
190	for range 150 {
191		m.Down()
192	}
193	frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles())
194	found := false
195	for y := 0; y < frame.Height; y++ {
196		if strings.Contains(rowText(frame, y), "n150") {
197			found = true
198			break
199		}
200	}
201	if !found {
202		t.Fatal("Render() scrolled the selected row out of view")
203	}
204}
205
206func TestCloneIsIndependentOfOriginal(t *testing.T) {
207	now := time.Unix(10000, 0)
208	m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now)
209	clone := m.Clone()
210	m.Down()
211	if got, _ := clone.Selected(); got.Message != "a" {
212		t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message)
213	}
214	if got, _ := m.Selected(); got.Message != "b" {
215		t.Fatalf("m.Selected() = %q, want b", got.Message)
216	}
217}
218
219func TestCloneOfNilIsNil(t *testing.T) {
220	var m *Model
221	if clone := m.Clone(); clone != nil {
222		t.Fatalf("Clone() of nil = %v, want nil", clone)
223	}
224}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close