1package ui
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10)
11
12// frameText joins every row of frame into one newline-separated string so
13// tests can search for substrings without caring which cells they land on.
14func frameText(f renderer.Frame) string {
15 rows := make([]string, f.Height)
16 for y := 0; y < f.Height; y++ {
17 var b strings.Builder
18 for x := 0; x < f.Width; x++ {
19 cell := f.At(x, y)
20 if cell.Continuation {
21 continue
22 }
23 if cell.Rune == 0 {
24 b.WriteByte(' ')
25 continue
26 }
27 b.WriteRune(cell.Rune)
28 }
29 rows[y] = b.String()
30 }
31 return strings.Join(rows, "\n")
32}
33
34// firstRowContaining returns the index of the first row in f whose rendered
35// text contains s, or -1 if no row does.
36func firstRowContaining(f renderer.Frame, s string) int {
37 for y, row := range strings.Split(frameText(f), "\n") {
38 if strings.Contains(row, s) {
39 return y
40 }
41 }
42 return -1
43}
44
45func TestComposeNotices(t *testing.T) {
46 styles := NoticeStyles{
47 Text: renderer.Style{Foreground: 7, Background: -1},
48 BoxInfo: renderer.Style{Foreground: 4, Background: -1},
49 BoxWarn: renderer.Style{Foreground: 3, Background: -1},
50 BoxError: renderer.Style{Foreground: 1, Background: -1},
51 }
52 isBorderRune := func(r rune) bool {
53 switch r {
54 case '┌', '┐', '└', '┘', '─', '│':
55 return true
56 default:
57 return false
58 }
59 }
60
61 tests := []struct {
62 name string
63 notices []NoticeView
64 overflow int
65 check func(t *testing.T, f renderer.Frame)
66 }{
67 {
68 name: "single error notice draws a bordered box top-right with title and message",
69 notices: []NoticeView{
70 {Severity: domain.NoticeError, Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
71 },
72 check: func(t *testing.T, f renderer.Frame) {
73 found := false
74 for y := 0; y <= 8 && !found; y++ {
75 for x := 40; x < f.Width; x++ {
76 if isBorderRune(f.At(x, y).Rune) {
77 found = true
78 break
79 }
80 }
81 }
82 if !found {
83 t.Fatalf("no box border found in top-right quadrant (x>=40, y<=8):\n%s", frameText(f))
84 }
85 text := frameText(f)
86 if !strings.Contains(text, "pane-spawn ×3") {
87 t.Fatalf("frame missing title %q:\n%s", "pane-spawn ×3", text)
88 }
89 if !strings.Contains(text, "couldn't open pane") {
90 t.Fatalf("frame missing message %q:\n%s", "couldn't open pane", text)
91 }
92 },
93 },
94 {
95 name: "three notices with overflow renders a +N more line",
96 notices: []NoticeView{
97 {Severity: domain.NoticeError, Title: "pane-spawn", Message: "one"},
98 {Severity: domain.NoticeWarn, Title: "tab-spawn", Message: "two"},
99 {Severity: domain.NoticeInfo, Title: "clipboard", Message: "three"},
100 },
101 overflow: 2,
102 check: func(t *testing.T, f renderer.Frame) {
103 text := frameText(f)
104 if !strings.Contains(text, "+2 more") {
105 t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
106 }
107 rowOne := firstRowContaining(f, "one")
108 rowTwo := firstRowContaining(f, "two")
109 rowThree := firstRowContaining(f, "three")
110 if rowOne < 0 || rowTwo < 0 || rowThree < 0 {
111 t.Fatalf("could not locate all three notice messages in frame: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text)
112 }
113 if rowOne >= rowTwo || rowTwo >= rowThree {
114 t.Fatalf("notices not stacked newest-first top-down: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text)
115 }
116 },
117 },
118 {
119 name: "no notices and no overflow draws nothing",
120 notices: nil,
121 overflow: 0,
122 check: func(t *testing.T, f renderer.Frame) {
123 want := renderer.NewFrame(f.Width, f.Height)
124 if frameText(f) != frameText(want) {
125 t.Fatalf("frame changed with no notices and no overflow:\n%s", frameText(f))
126 }
127 },
128 },
129 }
130
131 for _, tt := range tests {
132 t.Run(tt.name, func(t *testing.T) {
133 f := renderer.NewFrame(80, 24)
134 ComposeNotices(f, tt.notices, tt.overflow, styles)
135 tt.check(t, f)
136 })
137 }
138}
139
140func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
141 styles := NoticeStyles{
142 Text: renderer.Style{Foreground: 7, Background: -1},
143 BoxInfo: renderer.Style{Foreground: 4, Background: -1},
144 BoxWarn: renderer.Style{Foreground: 3, Background: -1},
145 BoxError: renderer.Style{Foreground: 1, Background: -1},
146 }
147
148 tests := []struct {
149 name string
150 sev domain.NoticeSeverity
151 want renderer.Style
152 }{
153 {"info", domain.NoticeInfo, styles.BoxInfo},
154 {"warn", domain.NoticeWarn, styles.BoxWarn},
155 {"error", domain.NoticeError, styles.BoxError},
156 }
157 for _, tt := range tests {
158 t.Run(tt.name, func(t *testing.T) {
159 f := renderer.NewFrame(80, 24)
160 ComposeNotices(f, []NoticeView{{Severity: tt.sev, Title: "x", Message: "y"}}, 0, styles)
161 corner := findRune(f, '┌')
162 if corner == nil {
163 t.Fatalf("no box top-left corner found:\n%s", frameText(f))
164 }
165 if !corner.Style.Equal(tt.want) {
166 t.Fatalf("box border style = %+v, want %s style %+v", corner.Style, tt.name, tt.want)
167 }
168 })
169 }
170}
171
172// findRune returns the first cell in f carrying r, or nil if none does.
173func findRune(f renderer.Frame, r rune) *renderer.Cell {
174 for y := 0; y < f.Height; y++ {
175 for x := 0; x < f.Width; x++ {
176 if cell := f.At(x, y); cell.Rune == r {
177 return &cell
178 }
179 }
180 }
181 return nil
182}
183
184// findRunePos returns the coordinates of the first cell in f carrying r, or
185// (-1, -1) if no cell does.
186func findRunePos(f renderer.Frame, r rune) (int, int) {
187 for y := 0; y < f.Height; y++ {
188 for x := 0; x < f.Width; x++ {
189 if f.At(x, y).Rune == r {
190 return x, y
191 }
192 }
193 }
194 return -1, -1
195}
196
197// TestComposeNoticesWidth pins the box's actual width and left edge across
198// representative terminal widths, so the test fails if the *2/5 ratio, the
199// 60-column cap, the 24-column floor, or the final frame-width clamp changes.
200// Each expected x and width below is worked out by hand from the formula
201// documented on ComposeNotices, not recomputed from the implementation.
202func TestComposeNoticesWidth(t *testing.T) {
203 styles := NoticeStyles{
204 Text: renderer.Style{Foreground: 7, Background: -1},
205 BoxInfo: renderer.Style{Foreground: 4, Background: -1},
206 BoxWarn: renderer.Style{Foreground: 3, Background: -1},
207 BoxError: renderer.Style{Foreground: 1, Background: -1},
208 }
209
210 tests := []struct {
211 name string
212 frameWidth int
213 wantWidth int
214 wantX int // -1 means the box's left edge falls off-frame (x < 0)
215 }{
216 // 200 cols: 200*2/5=80, capped at 60. x = 200 - 1(margin) - 60 = 139.
217 {name: "60-column cap binds on a wide terminal", frameWidth: 200, wantWidth: 60, wantX: 139},
218 // 100 cols: 100*2/5=40, under the cap and floor. x = 100 - 1 - 40 = 59.
219 {name: "cols*2/5 ratio binds on a mid terminal", frameWidth: 100, wantWidth: 40, wantX: 59},
220 // 40 cols: 40*2/5=16, floored to 24. x = 40 - 1 - 24 = 15.
221 {name: "24-column floor binds on a narrow terminal", frameWidth: 40, wantWidth: 24, wantX: 15},
222 // 20 cols: 20*2/5=8, floored to 24, then clamped down to frame.Width
223 // (20) since 24 > 20. x = 20 - 1(margin) - 20 = -1, so the left edge
224 // (and its corner rune) fall one column off-frame.
225 {name: "frame-width clamp overrides the floor on a tiny terminal", frameWidth: 20, wantWidth: 20, wantX: -1},
226 }
227
228 for _, tt := range tests {
229 t.Run(tt.name, func(t *testing.T) {
230 f := renderer.NewFrame(tt.frameWidth, 10)
231 ComposeNotices(f, []NoticeView{{Severity: domain.NoticeInfo, Title: "x", Message: "y"}}, 0, styles)
232
233 wantRight := tt.wantX + tt.wantWidth - 1
234 rightX, rightY := findRunePos(f, '┐')
235 if rightX < 0 {
236 t.Fatalf("no top-right corner found:\n%s", frameText(f))
237 }
238 if rightY != noticeMargin || rightX != wantRight {
239 t.Fatalf("top-right corner at (%d,%d), want (%d,%d):\n%s", rightX, rightY, wantRight, noticeMargin, frameText(f))
240 }
241
242 // The message body starts one column right of the box's left
243 // edge (wantX+1) and one row below its top edge. Pinning this
244 // rune's exact column catches width/x regressions that the
245 // top-right corner can't: right = x+width-1 = frame.Width-margin-1
246 // no matter what width is, so the right corner alone can't tell
247 // a correctly clamped box from one whose x drifted further
248 // off-frame (both still land off-screen).
249 msgX, msgY := tt.wantX+1, noticeMargin+1
250 if got := f.At(msgX, msgY).Rune; got != 'y' {
251 t.Fatalf("message rune at (%d,%d) = %q, want 'y':\n%s", msgX, msgY, got, frameText(f))
252 }
253
254 leftX, leftY := findRunePos(f, '┌')
255 if tt.wantX < 0 {
256 if leftX >= 0 {
257 t.Fatalf("expected left corner to be clipped off-frame, found one at (%d,%d):\n%s", leftX, leftY, frameText(f))
258 }
259 return
260 }
261 if leftX != tt.wantX || leftY != noticeMargin {
262 t.Fatalf("top-left corner at (%d,%d), want (%d,%d):\n%s", leftX, leftY, tt.wantX, noticeMargin, frameText(f))
263 }
264 if gotWidth := rightX - leftX + 1; gotWidth != tt.wantWidth {
265 t.Fatalf("box width = %d, want %d:\n%s", gotWidth, tt.wantWidth, frameText(f))
266 }
267 })
268 }
269}
270
271func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
272 styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
273 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
274 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
275 f := renderer.NewFrame(size.Cols, size.Rows)
276 notices := []NoticeView{{Severity: domain.NoticeError, Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
277 ComposeNotices(f, notices, 5, styles)
278 })
279 }
280}