Files
CrychicUI/layout/containers_test.go
T
ZacharyZhang-NYandClaude Opus 5.5 4efaf2680f Add layout components 0001-0025
Stack through StickyHeader, with behavior tests and gallery demos.
kit gains Arrive, Settle (paint-time layout check) and Shade.
Several actions return named ui.Element results; mygo vet forbids
elements in structs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 02:49:32 -04:00

254 lines
7.9 KiB
Go

package layout
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestStackGapsAndResizes(t *testing.T) {
more := false
var gap float32
tt := ui.NewTester(func(c *ui.Context) {
gap = c.Theme().Space(4)
Stack(c, 4, func() {
ui.Text(c, "Alpha")
ui.Text(c, "Beta")
if more {
ui.Text(c, "Gamma")
}
})
}, 300, 300)
a, b := find(t, tt, "Alpha"), find(t, tt, "Beta")
if got := b.Y - (a.Y + a.H); abs(got-gap) > 0.5 {
t.Errorf("gap %v, want %v", got, gap)
}
more = true
tt.Frame()
settles(t, tt, "Stack size")
find(t, tt, "Gamma")
}
func TestInlineWraps(t *testing.T) {
var gap float32
tt := ui.NewTester(func(c *ui.Context) {
gap = c.Theme().Space(2)
Inline(c, 2, func() {
for _, s := range []string{"Alpha", "Beta", "Gamma", "Delta", "Epsilon"} {
ui.Text(c, s)
}
})
}, 160, 300)
a, b, e := find(t, tt, "Alpha"), find(t, tt, "Beta"), find(t, tt, "Epsilon")
if got := b.X - (a.X + a.W); abs(got-gap) > 0.5 {
t.Errorf("gap %v, want %v", got, gap)
}
if e.Y <= a.Y {
t.Error("items did not wrap")
}
}
func TestLayersOverlapAndFade(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Layers(c, []func(){func() { ui.Text(c, "Back") }, func() { ui.Text(c, "Front") }})
}, 300, 200)
settles(t, tt, "Layers fade")
if b, f := find(t, tt, "Back"), find(t, tt, "Front"); b.X != f.X || b.Y != f.Y {
t.Errorf("layers apart: %v %v", b, f)
}
panics(t, "Layers(nil)", func() { ui.Render(func(c *ui.Context) { Layers(c, nil) }, 10, 10, 1) })
}
func TestCentered(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Centered(c, func() { ui.Text(c, "Mid") })
}, 300, 200)
settles(t, tt, "Centered scale")
r := find(t, tt, "Mid")
if abs(r.X+r.W/2-150) > 1 || abs(r.Y+r.H/2-100) > 1 {
t.Errorf("Mid at %v, not centered", r)
}
}
func TestContainerWidth(t *testing.T) {
var pad, rem float32
width := float32(400)
tt := ui.NewTester(func(c *ui.Context) {
pad, rem = c.Theme().Space(6), c.Theme().Rem(64)
Container(c, ContainerOptions{MaxWidth: width}, func() { ui.Text(c, "Edge") })
}, 1200, 200)
if x := find(t, tt, "Edge").X; abs(x-((1200-width)/2+pad)) > 0.5 {
t.Errorf("content at %v", x)
}
width = 0
tt.Frame()
settles(t, tt, "Container width")
if x := find(t, tt, "Edge").X; abs(x-((1200-rem)/2+pad)) > 0.5 {
t.Errorf("default width puts content at %v", x)
}
panics(t, "negative MaxWidth", func() {
ui.Render(func(c *ui.Context) { Container(c, ContainerOptions{MaxWidth: -1}, nil) }, 10, 10, 1)
})
}
// inked finds ink in a column span of pixels.
func inked(tt *ui.Tester, x int, y0, y1 float32) bool {
img := tt.Image()
for y := max(int(y0), 0); y <= min(int(y1), img.Bounds().Dy()-1); y++ {
if lum(img, x, y) < lum(img, 299, 199)-20000 {
return true
}
}
return false
}
func TestSectionDividerGrows(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Section(c, "Title", func() { ui.Text(c, "Body") })
}, 300, 200)
title, body := find(t, tt, "Title"), find(t, tt, "Body")
if body.Y <= title.Y {
t.Fatal("body above title")
}
if inked(tt, 280, title.Y+title.H, body.Y-1) {
t.Error("divider drawn before it grew")
}
settles(t, tt, "Section divider")
if !inked(tt, 280, title.Y+title.H, body.Y-1) {
t.Error("divider never reached the right edge")
}
}
func TestSurfaces(t *testing.T) {
var th ui.Theme
tt := ui.NewTester(func(c *ui.Context) {
th = *c.Theme()
ui.Column(c).Gap(10).Children(func() {
Panel(c, func() { ui.Box(c).Size(100, 40) })
Inset(c, func() { ui.Box(c).Size(100, 40) })
Well(c, func() { ui.Box(c).Size(100, 40) })
})
}, 300, 300)
tt.Advance(time.Second)
img := tt.Image()
for _, p := range [][2]int{{60, 30}, {60, 100}, {60, 170}} {
if !near(img, p[0], p[1], th.Surface) {
t.Errorf("pixel %v is not Surface", p)
}
}
tt.SetDark(true)
if !tt.FramePending() {
t.Error("Inset color did not animate to the dark theme")
}
tt.Advance(2 * time.Second)
if !near(tt.Image(), 60, 100, ui.DarkTheme().Surface) {
t.Error("Inset did not settle on the dark surface")
}
}
func TestCardLifts(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() {
Card(c, func() { ui.Box(c).Size(100, 60) })
})
}, 300, 200)
tt.Move(70, 50)
settles(t, tt, "Card lift")
}
func TestFrameClips(t *testing.T) {
var th ui.Theme
tt := ui.NewTester(func(c *ui.Context) {
th = *c.Theme()
ui.Column(c).Padding(20).Children(func() {
Frame(c, func() { ui.Box(c).Size(100, 60).Background(th.Accent) })
})
}, 300, 200)
settles(t, tt, "Frame fade")
if img := tt.Image(); near(img, 20, 20, th.Accent) || !near(img, 70, 50, th.Accent) {
t.Error("Frame did not clip its corner")
}
}
func TestAspectBox(t *testing.T) {
var th ui.Theme
tt := ui.NewTester(func(c *ui.Context) {
th = *c.Theme()
ui.Column(c).Width(300).Children(func() {
AspectBox(c, 2, func() { ui.Box(c).Fill().Background(th.Accent) })
})
}, 300, 300)
if img := tt.Image(); !near(img, 150, 140, th.Accent) || near(img, 150, 160, th.Accent) {
t.Error("AspectBox is not 300x150")
}
panics(t, "ratio 0", func() { ui.Render(func(c *ui.Context) { AspectBox(c, 0, nil) }, 10, 10, 1) })
}
func TestAppFrame(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
AppFrame(c, func() { ui.Text(c, "Top") }, func() { ui.Text(c, "Body") }, func() { ui.Text(c, "Status") })
}, 400, 300)
top, body, st := find(t, tt, "Top"), find(t, tt, "Body"), find(t, tt, "Status")
if !(top.Y < body.Y && body.Y < st.Y) {
t.Errorf("order %v %v %v", top, body, st)
}
mid := func(r ui.Rect) int { return int(r.X + r.W/2) }
if inked(tt, mid(top), top.Y, top.Y+top.H) || inked(tt, mid(st), st.Y, st.Y+st.H) {
t.Error("bars shown before they slid in")
}
settles(t, tt, "AppFrame bars")
if !inked(tt, mid(top), top.Y-2, top.Y+top.H+2) || !inked(tt, mid(st), st.Y-2, st.Y+st.H+2) {
t.Error("bars never arrived")
}
}
func TestHolyGrail(t *testing.T) {
aside := true
tt := ui.NewTester(func(c *ui.Context) {
s := HolyGrailSlots{
Header: func() { ui.Text(c, "Head") }, Nav: func() { ui.Text(c, "Nav") },
Main: func() { ui.Text(c, "Main") }, Footer: func() { ui.Text(c, "Foot") },
}
if aside {
s.Aside = func() { ui.Text(c, "Aside") }
}
HolyGrail(c, s)
}, 600, 400)
nav, main, side := find(t, tt, "Nav"), find(t, tt, "Main"), find(t, tt, "Aside")
if !(nav.X < main.X && main.X < side.X) || find(t, tt, "Head").Y >= main.Y || find(t, tt, "Foot").Y <= main.Y {
t.Error("slots out of place")
}
aside = false
tt.Frame()
settles(t, tt, "HolyGrail aside exit")
if tt.HasText("Aside") {
t.Error("aside still shown")
}
panics(t, "no Main", func() { ui.Render(func(c *ui.Context) { HolyGrail(c, HolyGrailSlots{}) }, 10, 10, 1) })
}
func TestRejectsBadNumbers(t *testing.T) {
nan, inf := float32(math.NaN()), float32(math.Inf(1))
none := func() {}
cases := map[string]func(c *ui.Context){
"Stack gap -1": func(c *ui.Context) { Stack(c, -1, none) },
"Stack gap NaN": func(c *ui.Context) { Stack(c, nan, none) },
"Inline gap Inf": func(c *ui.Context) { Inline(c, inf, none) },
"Container NaN": func(c *ui.Context) { Container(c, ContainerOptions{MaxWidth: nan}, none) },
"Container Inf": func(c *ui.Context) { Container(c, ContainerOptions{MaxWidth: inf}, none) },
"AspectBox NaN": func(c *ui.Context) { AspectBox(c, nan, none) },
"AspectBox Inf": func(c *ui.Context) { AspectBox(c, inf, none) },
"AutoGrid NaN": func(c *ui.Context) { AutoGrid(c, nan, nil) },
"AutoGrid Inf": func(c *ui.Context) { AutoGrid(c, inf, nil) },
"SplitPane size NaN": func(c *ui.Context) { SplitPane(c, &nan, none, none) },
"SplitStack size Inf": func(c *ui.Context) { SplitStack(c, &inf, none, none) },
"SplitPane size -1": func(c *ui.Context) { SplitPane(c, new(float32(-1)), none, none) },
"SplitStack size -1": func(c *ui.Context) { SplitStack(c, new(float32(-1)), none, none) },
}
for what, build := range cases {
panics(t, what, func() { ui.Render(func(c *ui.Context) { build(c) }, 10, 10, 1) })
}
}