-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathresponsive_test.go
More file actions
354 lines (313 loc) · 11.4 KB
/
Copy pathresponsive_test.go
File metadata and controls
354 lines (313 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
//go:build !js
package wui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
)
// renderAt renders el as a terminal of the given size would.
func renderAt(el Element, width, height int) string {
z := zone.New()
defer z.Close()
r := newTUIRenderer(width, height, z)
return z.Scan(r.Render(el))
}
func TestResponsiveSortsVariants(t *testing.T) {
el := Responsive(
At(80, Text("wide")),
At(0, Text("narrow")),
At(40, Text("mid")),
).(ResponsiveEl)
var got []int
for _, v := range el.Variants {
got = append(got, v.MinWidth)
}
if len(got) != 3 || got[0] != 0 || got[1] != 40 || got[2] != 80 {
t.Fatalf("variants not sorted ascending: %v", got)
}
}
func TestResponsivePicksByWidth(t *testing.T) {
el := Responsive(
At(0, Text("narrow")),
At(40, Text("mid")),
At(80, Text("wide")),
).(ResponsiveEl)
cases := []struct {
avail int
want string
}{
{0, "narrow"}, {1, "narrow"}, {39, "narrow"},
{40, "mid"}, {79, "mid"},
{80, "wide"}, {200, "wide"},
}
for _, c := range cases {
got := el.pick(c.avail).(TextEl).Content
if got != c.want {
t.Errorf("pick(%d) = %q, want %q", c.avail, got, c.want)
}
}
}
// Below every breakpoint the narrowest variant must still render, so a
// Responsive never draws nothing.
func TestResponsiveBelowAllBreakpointsUsesNarrowest(t *testing.T) {
el := Responsive(At(40, Text("mid")), At(80, Text("wide"))).(ResponsiveEl)
if got := el.pick(10).(TextEl).Content; got != "mid" {
t.Fatalf("pick(10) = %q, want the narrowest variant %q", got, "mid")
}
}
func TestResponsiveEmptyIsSafe(t *testing.T) {
el := Responsive().(ResponsiveEl)
if _, ok := el.pick(80).(EmptyEl); !ok {
t.Fatalf("empty Responsive did not pick Empty, got %T", el.pick(80))
}
if out := renderAt(el, 80, 24); strings.TrimSpace(out) != "" {
t.Fatalf("empty Responsive rendered %q", out)
}
}
// The variant is chosen from the terminal width when nothing narrower
// constrains the element.
func TestResponsiveUsesTerminalWidthAtRoot(t *testing.T) {
el := Responsive(At(0, Text("narrow")), At(60, Text("wide")))
if out := renderAt(el, 100, 10); !strings.Contains(out, "wide") {
t.Errorf("at 100 cols got %q, want the wide variant", out)
}
if out := renderAt(el, 30, 10); !strings.Contains(out, "narrow") {
t.Errorf("at 30 cols got %q, want the narrow variant", out)
}
}
// A Responsive inside a narrow sized Box must see the Box's inner
// width, not the terminal width — the container-relative promise.
func TestResponsiveUsesContainerWidth(t *testing.T) {
inner := Responsive(At(0, Text("narrow")), At(60, Text("wide")))
// A 30-cell column inside a 200-cell terminal.
tree := BoxStyled(Column, 0, NewStyle().W(30), inner)
out := renderAt(tree, 200, 10)
if !strings.Contains(out, "narrow") {
t.Errorf("inside a 30-cell box got %q, want the narrow variant", out)
}
if strings.Contains(out, "wide") {
t.Errorf("narrow container rendered the wide variant: %q", out)
}
}
// Padding eats into the width available to children.
func TestResponsiveAccountsForPadding(t *testing.T) {
inner := Responsive(At(0, Text("narrow")), At(40, Text("wide")))
// 44 cells wide with 4 columns of padding either side leaves 36.
tree := BoxStyled(Column, 0, NewStyle().W(44).Pad(0, 4), inner)
if out := renderAt(tree, 200, 10); !strings.Contains(out, "narrow") {
t.Errorf("with padding got %q, want the narrow variant", out)
}
}
// A Card's child sees the width inside the border and padding.
func TestResponsiveInsideCard(t *testing.T) {
inner := Responsive(At(0, Text("narrow")), At(40, Text("wide")))
tree := Card("T", inner, WithCardStyle(NewStyle().W(30)))
if out := renderAt(tree, 200, 10); !strings.Contains(out, "narrow") {
t.Errorf("inside a 30-cell card got %q, want the narrow variant", out)
}
}
// Walk must see every variant so lookups and tests can find controls
// that are hidden at the current width.
func TestResponsiveChildrenExposesAllVariants(t *testing.T) {
el := Responsive(At(0, Text("a")), At(40, Text("b")), At(80, Text("c")))
var seen []string
Walk(el, func(e Element) bool {
if t, ok := e.(TextEl); ok {
seen = append(seen, t.Content)
}
return true
})
if len(seen) != 3 {
t.Fatalf("Walk saw %v, want all three variants", seen)
}
}
// Focus, unlike Walk, must only see the visible variant: tabbing to a
// control that is not on screen would be a dead stop.
func TestResponsiveFocusOnlyVisibleVariant(t *testing.T) {
tree := Responsive(
At(0, Button("narrow-btn", func() Msg { return nil }, WithID("narrow"))),
At(60, Button("wide-btn", func() Msg { return nil }, WithID("wide"))),
)
ids := func(fs []focusable) []string {
out := make([]string, 0, len(fs))
for _, f := range fs {
out = append(out, f.ID)
}
return out
}
got := ids(collectFocusables(tree, 100))
if len(got) != 1 || got[0] != "btn:wide" {
t.Errorf("at 100 cols focusables = %v, want just the wide button", got)
}
got = ids(collectFocusables(tree, 20))
if len(got) != 1 || got[0] != "btn:narrow" {
t.Errorf("at 20 cols focusables = %v, want just the narrow button", got)
}
}
func TestResponsiveText(t *testing.T) {
el := ResponsiveText(NewStyle(), TextAt(0, "err"), TextAt(40, "connection failed"))
if out := renderAt(el, 100, 10); !strings.Contains(out, "connection failed") {
t.Errorf("wide render = %q", out)
}
if out := renderAt(el, 20, 10); !strings.Contains(strings.TrimSpace(out), "err") {
t.Errorf("narrow render = %q", out)
}
}
// A centered Column must center against its own width, not against its
// widest child — the bug that left a banner hugging the left edge.
func TestCenteredXCentersAgainstContainer(t *testing.T) {
tree := BoxStyled(Column, 0, NewStyle().W(40),
CenteredX(Text("hi")),
)
out := renderAt(tree, 80, 10)
line := strings.Split(out, "\n")[0]
lead := len(line) - len(strings.TrimLeft(line, " "))
if lead < 15 || lead > 21 {
t.Fatalf("expected %q centered in 40 cells, got %d leading spaces: %q", "hi", lead, line)
}
}
// CenteredX must not claim vertical space, so it is safe as an
// AppShell body where Centered would push the chrome off screen.
func TestCenteredXDoesNotFillHeight(t *testing.T) {
out := renderAt(CenteredX(Text("x")), 40, 20)
if got := lipgloss.Height(strings.TrimRight(out, "\n")); got != 1 {
t.Fatalf("CenteredX height = %d, want 1", got)
}
}
func TestAlignedXEnd(t *testing.T) {
tree := BoxStyled(Column, 0, NewStyle().W(20), AlignedX(AlignEnd, Text("hi")))
line := strings.Split(renderAt(tree, 80, 10), "\n")[0]
if !strings.HasSuffix(strings.TrimRight(line, " "), "hi") {
t.Fatalf("AlignEnd did not right-align: %q", line)
}
if lead := len(line) - len(strings.TrimLeft(line, " ")); lead < 15 {
t.Fatalf("AlignEnd left only %d leading spaces: %q", lead, line)
}
}
// AlignStart must stay the default behaviour.
func TestAlignedXStartUnchanged(t *testing.T) {
tree := BoxStyled(Column, 0, NewStyle().W(20), AlignedX(AlignStart, Text("hi")))
line := strings.Split(renderAt(tree, 80, 10), "\n")[0]
if !strings.HasPrefix(line, "hi") {
t.Fatalf("AlignStart moved content: %q", line)
}
}
// A Column with a fixed height must not let an oversized growing child
// push its later siblings out of view: AppShell's footer has to stay on
// screen even when the body overflows.
func TestColumnClipsOverflowingGrowChild(t *testing.T) {
tall := make([]Element, 40)
for i := range tall {
tall[i] = Text("body line")
}
tree := AppShell(
Text("HEADER"),
Box(Column, tall...),
Text("FOOTER"),
)
out := strings.TrimRight(RenderTUISized(tree, 40, 12), "\n")
lines := strings.Split(out, "\n")
if len(lines) > 12 {
t.Fatalf("frame is %d rows, want at most 12", len(lines))
}
if !strings.Contains(lines[0], "HEADER") {
t.Errorf("header missing: %q", lines[0])
}
if last := lines[len(lines)-1]; !strings.Contains(last, "FOOTER") {
t.Errorf("footer pushed off screen, last line is %q", last)
}
}
// Clipping must leave a Column that already fits completely alone.
func TestColumnDoesNotClipWhenItFits(t *testing.T) {
tree := AppShell(Text("HEADER"), Box(Column, Text("a"), Text("b")), Text("FOOTER"))
out := RenderTUISized(tree, 40, 20)
for _, want := range []string{"HEADER", "a", "b", "FOOTER"} {
if !strings.Contains(out, want) {
t.Errorf("%q missing from a frame that fits:\n%s", want, out)
}
}
}
// MaxWidth stops a Fill layout from stretching across a very wide
// terminal, and PlaceX puts the capped block where it belongs.
func TestConstrainCapsAndCenters(t *testing.T) {
body := BoxStyled(Column, 0, NewStyle().W(Fill).Bordered("6"), Text("content"))
for _, term := range []int{300, 200, 120} {
out := strings.TrimRight(RenderTUISized(Constrain(100, body), term, 10), "\n")
line := strings.Split(out, "\n")[0]
// Count runes, not bytes: the box-drawing border is multi-byte.
lead := len([]rune(line)) - len([]rune(strings.TrimLeft(line, " ")))
width := len([]rune(strings.TrimSpace(line)))
if width > 100 {
t.Errorf("at %d cols the block is %d wide, want at most 100", term, width)
}
// Centered: the left gap should be about half the slack.
wantLead := (term - width) / 2
if diff := lead - wantLead; diff < -1 || diff > 1 {
t.Errorf("at %d cols lead=%d, want ~%d (block %d wide)", term, lead, wantLead, width)
}
}
}
// Below the cap Constrain must change nothing.
func TestConstrainNoOpWhenNarrower(t *testing.T) {
body := BoxStyled(Column, 0, NewStyle().W(Fill), Text("content"))
plain := RenderTUISized(body, 60, 10)
capped := RenderTUISized(Constrain(100, body), 60, 10)
if plain != capped {
t.Fatalf("Constrain altered a layout narrower than the cap:\n%q\n%q", plain, capped)
}
}
// Children of a capped box must lay out against the cap, not the
// terminal — otherwise panes still drift apart inside the block.
func TestConstrainCapsChildWidth(t *testing.T) {
inner := Responsive(At(0, Text("narrow")), At(150, Text("wide")))
out := RenderTUISized(Constrain(100, inner), 300, 10)
if !strings.Contains(out, "narrow") {
t.Errorf("child saw the terminal width, not the 100-cell cap:\n%s", out)
}
if strings.Contains(out, "wide") {
t.Errorf("capped child rendered the wide variant:\n%s", out)
}
}
// A snug Sidebar keeps its panes adjacent instead of letting the main
// pane push them to opposite edges of a wide terminal.
func TestSidebarSnugKeepsPanesTogether(t *testing.T) {
mk := func(snug bool) Element {
opts := []func(*SidebarOpts){SidebarRight(), SidebarWidth(20), SidebarMinMain(20)}
if snug {
opts = append(opts, SidebarSnug())
}
return Sidebar(Card("S", Text("side")), Card("M", Text("main")), opts...)
}
gapOf := func(el Element) int {
line := strings.Split(RenderTUISized(el, 200, 10), "\n")[0]
// Widest run of spaces between the two card borders.
worst := 0
for _, run := range strings.Split(strings.TrimSpace(line), "╮") {
n := len(run) - len(strings.TrimLeft(run, " "))
if n > worst {
worst = n
}
}
return worst
}
stretched, snug := gapOf(mk(false)), gapOf(mk(true))
if snug >= stretched {
t.Fatalf("snug gap %d is not smaller than stretched gap %d", snug, stretched)
}
if snug > 4 {
t.Errorf("snug panes are still %d cells apart", snug)
}
}
// MaxHeight is the vertical counterpart and must not push chrome away.
func TestMaxHeightCaps(t *testing.T) {
tall := make([]Element, 30)
for i := range tall {
tall[i] = Text("line")
}
el := BoxStyled(Column, 0, NewStyle().H(Fill).MaxH(10), tall...)
out := strings.TrimRight(RenderTUISized(el, 40, 40), "\n")
if got := len(strings.Split(out, "\n")); got > 10 {
t.Fatalf("MaxHeight 10 rendered %d rows", got)
}
}