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}