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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | // 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. | |
| 43 | 46 | func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) { | |
| 44 | 47 | if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) { | |
| 45 | 48 | return | |
| @@ -35,11 +35,14 @@ type NoticeStyles struct { | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | // ComposeNotices stacks notice boxes top-right of frame, newest first. Each | 37 | // 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 | 38 | + | // 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 is | 39 | + | // 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 row | 40 | + | // never exceeds the frame — on frames narrower than the floor, that final |
| 41 | - | // between them; once the count behind overflow is nonzero, a right-aligned | 41 | + | // 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 | return | 48 | return | ||
M internal/usecase/ui/notice_test.go +107 -0
| @@ -31,6 +31,17 @@ func frameText(f renderer.Frame) string { | |||
| 31 | 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 | 45 | func TestComposeNotices(t *testing.T) { | |
| 35 | 46 | styles := NoticeStyles{ | |
| 36 | 47 | Text: renderer.Style{Foreground: 7, Background: -1}, | |
| @@ -93,6 +104,15 @@ func TestComposeNotices(t *testing.T) { | |||
| 93 | 104 | if !strings.Contains(text, "+2 more") { | |
| 94 | 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 | 181 | 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 | 271 | func TestComposeNoticesHandlesTinyFrames(t *testing.T) { | |
| 165 | 272 | styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()} | |
| 166 | 273 | 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 nil | 181 | 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}} { | ||