detached af22256

test(ui): pin notice box width formula and newest-first stacking order

TestComposeNotices only scanned for any border rune at x>=40, so it passed
regardless of the actual width formula (ratio/60-cap/24-floor/frame clamp),
and only checked for the "+2 more" overflow line without verifying notices
stack newest-first. Add TestComposeNoticesWidth pinning the exact box x and
width (plus message position, since the right corner's column is invariant
to width and can't alone catch a broken clamp) across representative
terminal widths, and extend the three-notice case to assert strict
top-down row ordering.
commit
af22256cd1c688fb32c01d568ce1293c1091da2d
author
brice · · 2026-07-20 13:12 UTC
parent
82b7fa1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +115 -5
M internal/usecase/ui/notice.go +8 -5
@@ -35,11 +35,14 @@ type NoticeStyles struct {
3535 }
3636
3737 // ComposeNotices stacks notice boxes top-right of frame, newest first. Each
38-// box is min(60, frame.Width*2/5) wide (floored at 24), titled with the
39-// notice's slug and an optional " ×N" count suffix, and its message is
40-// word-wrapped to at most 3 lines. Boxes stack downward with one blank row
41-// between them; once the count behind overflow is nonzero, a right-aligned
42-// "+N more" line follows the last box.
38+// box's width starts at frame.Width*2/5, is capped at noticeMaxWidth (60),
39+// then floored at noticeMinWidth (24), then clamped down to frame.Width so it
40+// never exceeds the frame — on frames narrower than the floor, that final
41+// clamp overrides the floor. Each box is titled with the notice's slug and an
42+// optional " ×N" count suffix, and its message is word-wrapped to at most 3
43+// lines. Boxes stack downward with one blank row between them; once the
44+// count behind overflow is nonzero, a right-aligned "+N more" line follows
45+// the last box.
4346 func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {
4447 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
4548 return
@@ -35,11 +35,14 @@ type NoticeStyles struct {
35 }35 }
36 36
37 // ComposeNotices stacks notice boxes top-right of frame, newest first. Each37 // ComposeNotices stacks notice boxes top-right of frame, newest first. Each
38-// box is min(60, frame.Width*2/5) wide (floored at 24), titled with the38+// box's width starts at frame.Width*2/5, is capped at noticeMaxWidth (60),
39-// notice's slug and an optional " ×N" count suffix, and its message is39+// then floored at noticeMinWidth (24), then clamped down to frame.Width so it
40-// word-wrapped to at most 3 lines. Boxes stack downward with one blank row40+// never exceeds the frame — on frames narrower than the floor, that final
41-// between them; once the count behind overflow is nonzero, a right-aligned41+// clamp overrides the floor. Each box is titled with the notice's slug and an
42-// "+N more" line follows the last box.42+// optional " ×N" count suffix, and its message is word-wrapped to at most 3
43+// lines. Boxes stack downward with one blank row between them; once the
44+// count behind overflow is nonzero, a right-aligned "+N more" line follows
45+// the last box.
43 func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {46 func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) {
44 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {47 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
45 return48 return
M internal/usecase/ui/notice_test.go +107 -0
@@ -31,6 +31,17 @@ func frameText(f renderer.Frame) string {
3131 return strings.Join(rows, "\n")
3232 }
3333
34+// firstRowContaining returns the index of the first row in f whose rendered
35+// text contains s, or -1 if no row does.
36+func 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+
3445 func TestComposeNotices(t *testing.T) {
3546 styles := NoticeStyles{
3647 Text: renderer.Style{Foreground: 7, Background: -1},
@@ -93,6 +104,15 @@ func TestComposeNotices(t *testing.T) {
93104 if !strings.Contains(text, "+2 more") {
94105 t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
95106 }
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+ }
96116 },
97117 },
98118 {
@@ -161,6 +181,93 @@ func findRune(f renderer.Frame, r rune) *renderer.Cell {
161181 return nil
162182 }
163183
184+// findRunePos returns the coordinates of the first cell in f carrying r, or
185+// (-1, -1) if no cell does.
186+func 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.
202+func 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: uint8(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+
164271 func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
165272 styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
166273 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
@@ -31,6 +31,17 @@ func frameText(f renderer.Frame) string {
31 return strings.Join(rows, "\n")31 return strings.Join(rows, "\n")
32 }32 }
33 33
34+// firstRowContaining returns the index of the first row in f whose rendered
35+// text contains s, or -1 if no row does.
36+func 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+
34 func TestComposeNotices(t *testing.T) {45 func TestComposeNotices(t *testing.T) {
35 styles := NoticeStyles{46 styles := NoticeStyles{
36 Text: renderer.Style{Foreground: 7, Background: -1},47 Text: renderer.Style{Foreground: 7, Background: -1},
@@ -93,6 +104,15 @@ func TestComposeNotices(t *testing.T) {
93 if !strings.Contains(text, "+2 more") {104 if !strings.Contains(text, "+2 more") {
94 t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)105 t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
95 }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+ }
96 },116 },
97 },117 },
98 {118 {
@@ -161,6 +181,93 @@ func findRune(f renderer.Frame, r rune) *renderer.Cell {
161 return nil181 return nil
162 }182 }
163 183
184+// findRunePos returns the coordinates of the first cell in f carrying r, or
185+// (-1, -1) if no cell does.
186+func 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.
202+func 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: uint8(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+
164 func TestComposeNoticesHandlesTinyFrames(t *testing.T) {271 func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
165 styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}272 styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
166 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {273 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {

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