| 🛟 Updated. 28d5985 k33g 20h ago | 1 | package ui |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/gdamore/tcell/v2" |
| 8 | |
| 📦 Turbo Core f3ade8d k33g 13h ago | 9 | "rickub.com/turbo-editors/turbo-core/theme" |
| 🛟 Updated. 28d5985 k33g 20h ago | 10 | ) |
| 11 | |
| 12 | // stubContent is a widget that records what it was given, so a container's |
| 13 | // routing can be checked without a real editor behind it. |
| 14 | type stubContent struct { |
| 15 | Box |
| 16 | keys int |
| 17 | clicks int |
| 18 | drawnAt Rect |
| 19 | consume bool |
| 20 | } |
| 21 | |
| 22 | func (s *stubContent) Draw(p *Painter, _ *theme.Theme) { |
| 23 | s.drawnAt = p.Size() |
| 24 | p.Fill(p.Size(), '·', tcell.StyleDefault) |
| 25 | } |
| 26 | |
| 27 | func (s *stubContent) HandleKey(*tcell.EventKey) bool { |
| 28 | s.keys++ |
| 29 | return s.consume |
| 30 | } |
| 31 | |
| 32 | func (s *stubContent) HandleMouse(*tcell.EventMouse) bool { |
| 33 | s.clicks++ |
| 34 | return s.consume |
| 35 | } |
| 36 | |
| 37 | func TestSplitHotKey(t *testing.T) { |
| 38 | tests := []struct { |
| 39 | label string |
| 40 | wantText string |
| 41 | wantKey rune |
| 42 | wantIndex int |
| 43 | }{ |
| 44 | {"~F~ile", "File", 'f', 0}, |
| 45 | {"E~x~it", "Exit", 'x', 1}, |
| 46 | {"Save ~A~s…", "Save As…", 'a', 5}, |
| 47 | {"No hot key", "No hot key", 0, -1}, |
| 48 | {"~", "", 0, -1}, |
| 49 | } |
| 50 | |
| 51 | for _, tc := range tests { |
| 52 | t.Run(tc.label, func(t *testing.T) { |
| 53 | text, key, index := SplitHotKey(tc.label) |
| 54 | if text != tc.wantText || key != tc.wantKey || index != tc.wantIndex { |
| 55 | t.Errorf("SplitHotKey(%q) = (%q, %q, %d), want (%q, %q, %d)", |
| 56 | tc.label, text, key, index, tc.wantText, tc.wantKey, tc.wantIndex) |
| 57 | } |
| 58 | }) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestLabelWidthIgnoresTheMarkers(t *testing.T) { |
| 63 | if got := LabelWidth("~F~ile"); got != 4 { |
| 64 | t.Errorf("LabelWidth(\"~F~ile\") = %d, want 4", got) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestMatchesHotKeyIgnoresCase(t *testing.T) { |
| 69 | if !MatchesHotKey("~F~ile", 'F') || !MatchesHotKey("~F~ile", 'f') { |
| 70 | t.Error("a hot key must match in either case") |
| 71 | } |
| 72 | if MatchesHotKey("~F~ile", 'x') || MatchesHotKey("File", 'f') { |
| 73 | t.Error("a hot key matched something it should not") |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestDrawFrameDrawsOnlyTheEdges(t *testing.T) { |
| 78 | screen, root := newTestScreen(t, 8, 4) |
| 79 | |
| 80 | DrawFrame(root, Rect{X: 0, Y: 0, W: 6, H: 3}, FrameDouble, tcell.StyleDefault) |
| 81 | screen.Show() |
| 82 | |
| 83 | lines := screenLines(t, screen) |
| 84 | want := []string{"╔════╗ ", "║ ║ ", "╚════╝ ", " "} |
| 85 | for i, w := range want { |
| 86 | if lines[i] != w { |
| 87 | t.Errorf("row %d = %q, want %q", i, lines[i], w) |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestDrawFrameNeedsRoomForItsCorners(t *testing.T) { |
| 93 | screen, root := newTestScreen(t, 4, 2) |
| 94 | |
| 95 | DrawFrame(root, Rect{X: 0, Y: 0, W: 1, H: 5}, FrameSingle, tcell.StyleDefault) |
| 96 | screen.Show() |
| 97 | |
| 98 | for _, line := range screenLines(t, screen) { |
| 99 | if strings.TrimSpace(line) != "" { |
| 100 | t.Errorf("a frame too narrow to have corners drew something: %q", line) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestThumbOffsetStaysOnTheTrack(t *testing.T) { |
| 106 | tests := []struct { |
| 107 | name string |
| 108 | position, span, total, track int |
| 109 | want int |
| 110 | }{ |
| 111 | {"at the top", 0, 10, 100, 8, 0}, |
| 112 | {"at the bottom", 90, 10, 100, 8, 7}, |
| 113 | {"nothing to scroll", 0, 100, 10, 8, 0}, |
| 114 | {"past the end", 999, 10, 100, 8, 7}, |
| 115 | {"a track too short", 5, 10, 100, 1, 0}, |
| 116 | } |
| 117 | |
| 118 | for _, tc := range tests { |
| 119 | t.Run(tc.name, func(t *testing.T) { |
| 120 | got := thumbOffset(tc.position, tc.span, tc.total, tc.track) |
| 121 | if got != tc.want { |
| 122 | t.Errorf("thumbOffset() = %d, want %d", got, tc.want) |
| 123 | } |
| 124 | }) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestWindowDrawsAFrameATitleAndItsContent(t *testing.T) { |
| 129 | screen, root := newTestScreen(t, 30, 8) |
| 130 | content := &stubContent{} |
| 131 | w := NewWindow("main.go", content) |
| 132 | w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 5}) |
| 133 | w.SetActive(true) |
| 134 | w.SetNumber(1) |
| 135 | |
| 136 | w.Draw(root, testTheme(t)) |
| 137 | screen.Show() |
| 138 | |
| 139 | lines := screenLines(t, screen) |
| 140 | if !strings.HasPrefix(lines[0], "╔") { |
| 141 | t.Errorf("an active window's frame is %q, want a double line", lines[0]) |
| 142 | } |
| 143 | if !strings.Contains(lines[0], "main.go") { |
| 144 | t.Errorf("the title bar is %q, want it to show the title", lines[0]) |
| 145 | } |
| 146 | if !strings.Contains(lines[0], closeBoxLabel) { |
| 147 | t.Errorf("the title bar is %q, want a close box", lines[0]) |
| 148 | } |
| 149 | if !strings.Contains(lines[0], "1") { |
| 150 | t.Errorf("the title bar is %q, want the window number", lines[0]) |
| 151 | } |
| 152 | if got := content.drawnAt; got != (Rect{W: 18, H: 3}) { |
| 153 | t.Errorf("the content was drawn in %+v, want the interior of the frame", got) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func TestAnInactiveWindowGetsASingleFrame(t *testing.T) { |
| 158 | screen, root := newTestScreen(t, 20, 5) |
| 159 | w := NewWindow("x", nil) |
| 160 | w.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 4}) |
| 161 | |
| 162 | w.Draw(root, testTheme(t)) |
| 163 | screen.Show() |
| 164 | |
| 165 | if got := screenLines(t, screen)[0]; !strings.HasPrefix(got, "┌") { |
| 166 | t.Errorf("an inactive window's frame is %q, want a single line", got) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestWindowPassesInteriorClicksToItsContent(t *testing.T) { |
| 171 | content := &stubContent{consume: true} |
| 172 | w := NewWindow("t", content) |
| 173 | w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) |
| 174 | |
| 175 | if !w.HandleMouse(clickEvent(5, 3)) { |
| 176 | t.Fatal("a click inside the window was not handled") |
| 177 | } |
| 178 | if content.clicks != 1 { |
| 179 | t.Errorf("the content saw %d clicks, want 1", content.clicks) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestAClickOutsideTheWindowIsNotItsBusiness(t *testing.T) { |
| 184 | content := &stubContent{} |
| 185 | w := NewWindow("t", content) |
| 186 | w.SetBounds(Rect{X: 10, Y: 10, W: 5, H: 5}) |
| 187 | |
| 188 | if w.HandleMouse(clickEvent(0, 0)) { |
| 189 | t.Error("the window claimed a click that landed elsewhere") |
| 190 | } |
| 191 | if content.clicks != 0 { |
| 192 | t.Error("the content was given a click from outside the window") |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestClickingTheCloseBoxAsksToClose(t *testing.T) { |
| 197 | asked := 0 |
| 198 | w := NewWindow("t", nil) |
| 199 | w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) |
| 200 | w.OnClose = func() bool { asked++; return true } |
| 201 | |
| 202 | w.HandleMouse(clickEvent(3, 0)) // the middle of "[■]" at column 2 |
| 203 | |
| 204 | if asked != 1 { |
| 205 | t.Errorf("OnClose was called %d times, want 1", asked) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestDraggingTheTitleBarMovesTheWindow(t *testing.T) { |
| 210 | w := NewWindow("t", nil) |
| 211 | w.SetBounds(Rect{X: 2, Y: 2, W: 20, H: 6}) |
| 212 | |
| 213 | w.HandleMouse(clickEvent(10, 2)) // press on the title bar |
| 214 | w.HandleMouse(tcell.NewEventMouse(14, 5, tcell.Button1, tcell.ModNone)) // drag |
| 215 | |
| 216 | if got := w.Bounds(); got != (Rect{X: 6, Y: 5, W: 20, H: 6}) { |
| 217 | t.Errorf("Bounds() = %+v, want the window moved by (4, 3)", got) |
| 218 | } |
| 219 | if !w.Dragging() { |
| 220 | t.Error("Dragging() = false while the button is still down") |
| 221 | } |
| 222 | |
| 223 | w.HandleMouse(releaseEvent(14, 5)) |
| 224 | if w.Dragging() { |
| 225 | t.Error("Dragging() = true after the button came back up") |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func TestDraggingTheCornerResizesTheWindow(t *testing.T) { |
| 230 | w := NewWindow("t", nil) |
| 231 | w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) |
| 232 | |
| 233 | w.HandleMouse(clickEvent(19, 5)) // the bottom-right corner |
| 234 | w.HandleMouse(tcell.NewEventMouse(25, 9, tcell.Button1, tcell.ModNone)) |
| 235 | |
| 236 | if got := w.Bounds(); got != (Rect{X: 0, Y: 0, W: 26, H: 10}) { |
| 237 | t.Errorf("Bounds() = %+v, want it grown by (6, 4)", got) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestAWindowCannotBeResizedBelowItsMinimum(t *testing.T) { |
| 242 | w := NewWindow("t", nil) |
| 243 | w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) |
| 244 | |
| 245 | w.HandleMouse(clickEvent(19, 5)) |
| 246 | w.HandleMouse(tcell.NewEventMouse(0, 0, tcell.Button1, tcell.ModNone)) |
| 247 | |
| 248 | got := w.Bounds() |
| 249 | if got.W != MinWindowWidth || got.H != MinWindowHeight { |
| 250 | t.Errorf("Bounds() = %+v, want it clamped to the minimum size", got) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | func TestSettingWindowBoundsLaysTheContentOut(t *testing.T) { |
| 255 | content := &stubContent{} |
| 256 | w := NewWindow("t", content) |
| 257 | |
| 258 | w.SetBounds(Rect{X: 3, Y: 4, W: 20, H: 10}) |
| 259 | |
| 260 | if got := content.Bounds(); got != (Rect{X: 4, Y: 5, W: 18, H: 8}) { |
| 261 | t.Errorf("the content is at %+v, want the interior of the frame", got) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func TestTheCloseBoxIsThreeColumnsWide(t *testing.T) { |
| 266 | // The block characters in "[■]" and "[▬]" are three bytes but one column, |
| 267 | // so a width measured in bytes would make a box swallow its neighbours. |
| 268 | for _, label := range []string{closeBoxLabel, maximizeBoxLabel, restoreBoxLabel} { |
| 269 | if got := len([]rune(label)); got != boxWidth { |
| 270 | t.Fatalf("%q is %d columns, but boxWidth says %d", label, got, boxWidth) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | closed := 0 |
| 275 | w := NewWindow("t", nil) |
| 276 | w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) |
| 277 | w.OnClose = func() bool { closed++; return true } |
| 278 | |
| 279 | for _, x := range []int{2, 3, 4} { |
| 280 | w.HandleMouse(clickEvent(x, 0)) |
| 281 | } |
| 282 | if closed != 3 { |
| 283 | t.Errorf("clicking the three columns of the close box asked to close %d times, want 3", closed) |
| 284 | } |
| 285 | |
| 286 | w.HandleMouse(clickEvent(5, 0)) // just past it: this starts a move instead |
| 287 | if closed != 3 { |
| 288 | t.Error("a click past the close box was taken as a close") |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestAWindowTellsItsContentWhetherItIsActive(t *testing.T) { |
| 293 | content := &focusableStub{} |
| 294 | w := NewWindow("t", content) |
| 295 | |
| 296 | w.SetActive(true) |
| 297 | if !content.Focused() { |
| 298 | t.Error("an active window did not give its content the focus") |
| 299 | } |
| 300 | |
| 301 | w.SetActive(false) |
| 302 | if content.Focused() { |
| 303 | t.Error("an inactive window left the focus with its content") |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | func TestAWindowWhoseContentCannotFocusStillWorks(t *testing.T) { |
| 308 | w := NewWindow("t", &stubContent{}) // embeds Box, not FocusBox |
| 309 | |
| 310 | w.SetActive(true) // must not panic |
| 311 | |
| 312 | if !w.Active() { |
| 313 | t.Error("Active() = false after SetActive(true)") |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // focusableStub is a content widget that can hold the focus. |
| 318 | type focusableStub struct { |
| 319 | FocusBox |
| 320 | } |
| 321 | |
| 322 | func (f *focusableStub) Draw(*Painter, *theme.Theme) {} |