Files

494 lines
14 KiB
Go

package input_test
import (
"image"
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func view(w, h int, clk *clock, build func(c *ui.Context)) *ui.Tester {
if clk == nil {
clk = &clock{time.Unix(3_000_000, 0)}
}
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now})
build(c)
}, w, h)
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
// slots are the palette slots of a box's dots.
func slots(t *testing.T, img *image.RGBA, r ui.Rect) []pixel.Slot {
t.Helper()
var out []pixel.Slot
for y := int(r.Y); y < int(r.Y+r.H); y += 2 {
for x := int(r.X); x < int(r.X+r.W); x += 2 {
c, found := img.RGBAAt(x, y), false
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
out, found = append(out, pixel.Slot(s)), true
break
}
}
if !found {
t.Fatalf("pixel %d,%d off the palette", x, y)
}
}
}
return out
}
// ink is where a box holds Ink, ignoring its background.
func ink(t *testing.T, img *image.RGBA, r ui.Rect) []bool {
t.Helper()
var out []bool
for _, s := range slots(t, img, r) {
out = append(out, s == pixel.Ink)
}
return out
}
func has(s []pixel.Slot, want pixel.Slot) bool {
for _, v := range s {
if v == want {
return true
}
}
return false
}
func TestButtonClicksKeysLoadingAndSizes(t *testing.T) {
clicks, opts := 0, input.ButtonOptions{Variant: input.Primary}
tt := view(400, 200, nil, func(c *ui.Context) {
if input.Button(c, "GO", opts).Clicked {
clicks++
}
input.Button(c, "S", input.ButtonOptions{Size: input.Small})
input.Button(c, "L", input.ButtonOptions{Size: input.Large})
})
click(t, tt, "GO")
tt.Key(0, ui.KeyEnter)
if clicks != 2 {
t.Fatalf("click and Enter gave %d clicks", clicks)
}
if !has(slots(t, tt.Image(), find(t, tt, "GO")), pixel.Accent) {
t.Fatal("a primary button has no Accent face")
}
if s, l := find(t, tt, "S"), find(t, tt, "L"); s.H != 2*pixel.LineHeight || l.H != 2*(2*pixel.LineHeight+4) {
t.Fatalf("small %v, large %v", s.H, l.H)
}
r := find(t, tt, "GO")
top := func() pixel.Slot { return slots(t, tt.Image(), ui.Rect{X: r.X + r.W/2, Y: r.Y, W: 2, H: 2})[0] }
up := top()
tt.Press(r.X+r.W/2, r.Y+r.H/2)
if top() == up {
t.Fatal("pressing did not sink the face")
}
tt.Release(r.X+r.W/2, r.Y+r.H/2)
opts.Loading = true
tt.Frame()
click(t, tt, "GO")
opts.Loading, opts.Disabled = false, true
tt.Frame()
click(t, tt, "GO")
if clicks != 3 {
t.Fatalf("loading or disabled clicked: %d clicks", clicks)
}
}
func TestIconButtonNeedsAName(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("a nameless icon button did not panic")
}
}()
view(100, 100, nil, func(c *ui.Context) { input.IconButton(c, icon.Star, "", input.IconButtonOptions{}) })
}
func TestButtonGroupIsOneTabStop(t *testing.T) {
got := -1
tt := view(400, 100, nil, func(c *ui.Context) {
if r := input.ButtonGroup(c, []string{"A", "B", "C"}, input.ButtonGroupOptions{}); r.Clicked >= 0 {
got = r.Clicked
}
input.Button(c, "after", input.ButtonOptions{})
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if got != 1 {
t.Fatalf("Tab, Right, Enter clicked %d", got)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("after") {
t.Fatal("Tab did not leave the group")
}
}
func TestTogglesChecksAndSwitch(t *testing.T) {
var on, checked, lever bool
modes := []bool{true, false, false}
tt := view(500, 200, nil, func(c *ui.Context) {
input.Toggle(c, "MUSIC", &on, input.ToggleOptions{})
input.Checkbox(c, "Remember", &checked, input.CheckboxOptions{})
input.Switch(c, "Power", &lever, input.SwitchOptions{})
input.ToggleGroup(c, []string{"E", "N", "H"}, modes, input.ToggleGroupOptions{Single: true})
})
click(t, tt, "MUSIC")
click(t, tt, "Remember")
tt.Key(0, ui.KeySpace)
click(t, tt, "Power")
click(t, tt, "H")
if !on || checked || !lever || modes[0] || !modes[2] {
t.Fatalf("toggle %v, checkbox %v after Space, switch %v, modes %v", on, checked, lever, modes)
}
defer func() {
if recover() == nil {
t.Fatal("a short state slice did not panic")
}
}()
view(100, 100, nil, func(c *ui.Context) {
input.ToggleGroup(c, []string{"a", "b"}, []bool{true}, input.ToggleGroupOptions{})
})
}
func TestCheckboxGroupParent(t *testing.T) {
checked := []bool{true, false}
tt := view(300, 200, nil, func(c *ui.Context) {
input.CheckboxGroup(c, "Party", []string{"Knight", "Mage"}, checked, input.CheckboxGroupOptions{})
})
if !has(slots(t, tt.Image(), find(t, tt, "Party")), pixel.Accent) {
t.Fatal("a half-checked group shows no mixed mark")
}
click(t, tt, "Party")
if !checked[0] || !checked[1] {
t.Fatalf("parent on a mixed group checked %v", checked)
}
click(t, tt, "Party")
if checked[0] || checked[1] {
t.Fatalf("parent on a full group left %v", checked)
}
}
func TestRadiosChoose(t *testing.T) {
class, season := "knight", 0
tt := view(400, 200, nil, func(c *ui.Context) {
input.Radio(c, "knight", "knight", &class, input.RadioOptions{})
input.Radio(c, "mage", "mage", &class, input.RadioOptions{})
input.Button(c, "before", input.ButtonOptions{})
input.RadioGroup(c, []string{"Spring", "Summer", "Autumn"}, &season, input.RadioGroupOptions{})
})
click(t, tt, "mage")
click(t, tt, "mage")
if class != "mage" {
t.Fatalf("radio left %q", class)
}
click(t, tt, "Autumn")
if season != 2 {
t.Fatalf("click chose %d", season)
}
click(t, tt, "before")
tt.Key(0, ui.KeyTab)
if !tt.Focused("Autumn") || season != 2 {
t.Fatal("Tab entered elsewhere than the chosen radio, or chose")
}
tt.Key(0, ui.KeyUp)
if season != 1 {
t.Fatalf("Up chose %d, want 1", season)
}
}
func TestSlidersDragKeysAndSteps(t *testing.T) {
v, s, low, high, f := 0.0, 0.0, 20.0, 60.0, 0.0
disabled := false
tt := view(500, 300, nil, func(c *ui.Context) {
ui.Column(c).Gap(10).Children(func() {
input.Slider(c, "vol", &v, input.SliderOptions{Width: 100, Disabled: disabled})
input.StepSlider(c, "speed", &s, input.StepSliderOptions{Min: 0, Max: 100, Step: 20, Width: 100})
input.RangeSlider(c, "lv", &low, &high, input.RangeSliderOptions{Min: 0, Max: 100, Width: 100})
input.Fader(c, "master", &f, input.FaderOptions{Height: 50})
})
})
r := find(t, tt, "vol")
tt.ClickAt(r.X+r.W*0.75, r.Y+r.H/2)
if v < 0.7 || v > 0.8 {
t.Fatalf("a click three quarters along set %v", v)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyRight)
if v != 0.05 {
t.Fatalf("Home then Right gave %v, want 0.05", v)
}
sr := find(t, tt, "speed")
tt.ClickAt(sr.X+100*2*0.33, sr.Y+sr.H/2)
if s != 40 {
t.Fatalf("a third along snapped to %v, want 40", s)
}
tt.Key(0, ui.KeyRight)
if _, shown := tt.Find("60"); s != 60 || !shown {
t.Fatalf("Right stepped to %v, shown %v, want 60", s, shown)
}
lowKnob := find(t, tt, "lv low")
tt.Press(lowKnob.X+lowKnob.W/2, lowKnob.Y+lowKnob.H/2)
tt.Move(lowKnob.X+190, lowKnob.Y+lowKnob.H/2)
tt.Release(lowKnob.X+190, lowKnob.Y+lowKnob.H/2)
if low != high {
t.Fatalf("dragging low past high left %v and %v", low, high)
}
fr := find(t, tt, "master")
tt.ClickAt(fr.X+fr.W/2, fr.Y+2)
if f < 0.9 {
t.Fatalf("a click at the fader's top set %v", f)
}
disabled, v = true, 0.5
tt.Frame()
tt.ClickAt(r.X+2, r.Y+r.H/2)
if v != 0.5 {
t.Fatal("a disabled slider moved")
}
}
func TestStepperRepeatsWhileHeld(t *testing.T) {
clk := &clock{time.Unix(3_000_000, 0)}
n := 5
tt := view(300, 100, clk, func(c *ui.Context) {
input.Stepper(c, "Lives", &n, input.StepperOptions{Min: 0, Max: 20})
})
r := find(t, tt, "Lives")
right := r.X + r.W + 4 + 12
tt.Press(right, r.Y+r.H/2)
if n != 6 {
t.Fatalf("a press stepped to %d", n)
}
clk.t = clk.t.Add(300 * time.Millisecond)
tt.Frame()
if n != 6 {
t.Fatalf("repeat began early: %d", n)
}
clk.t = clk.t.Add(400 * time.Millisecond)
tt.Frame()
tt.Release(right, r.Y+r.H/2)
if n != 9 {
t.Fatalf("700 ms held gave %d, want 9", n)
}
click(t, tt, "Lives")
tt.Key(0, ui.KeyDown)
if n != 8 {
t.Fatalf("Down stepped to %d", n)
}
n = 30
tt.Key(0, ui.KeyUp)
if n != 20 {
t.Fatalf("Up past the top gave %d", n)
}
}
func TestNumberInputParsesAndSteps(t *testing.T) {
v := 5.0
tt := view(300, 100, nil, func(c *ui.Context) {
input.NumberInput(c, "Price", &v, input.NumberInputOptions{Min: 0, Max: 10})
})
click(t, tt, "Price")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("7.5")
if v != 7.5 {
t.Fatalf("typing 7.5 left %v", v)
}
border := func() []pixel.Slot {
r := find(t, tt, "Price")
return slots(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y - 6, W: r.W, H: 2})
}
tt.Type("x")
if v != 7.5 || !has(border(), pixel.Bad) {
t.Fatalf("bad text changed the value to %v, or the edge stayed: %v", v, border()[:3])
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("9")
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyUp)
if v != 10 {
t.Fatalf("Up twice from 9 gave %v, want the max 10", v)
}
}
func TestFieldsTypeSubmitClear(t *testing.T) {
name, query, secret := "", "slime", "abc"
submits := 0
tt := view(500, 300, nil, func(c *ui.Context) {
if input.TextInput(c, "Name", &name, input.TextInputOptions{MaxLength: 3}).Submitted {
submits++
}
input.SearchField(c, "Find", &query, input.SearchFieldOptions{})
input.PasswordInput(c, "Secret", &secret, input.PasswordInputOptions{})
})
click(t, tt, "Name")
tt.Type("Link!")
tt.Key(0, ui.KeyEnter)
if name != "Lin" || submits != 1 {
t.Fatalf("name %q, submits %d", name, submits)
}
click(t, tt, "Find")
tt.Key(0, ui.KeyEscape)
if query != "" {
t.Fatalf("Escape left %q", query)
}
secretInk := func() []bool {
r := find(t, tt, "Secret")
return ink(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y, W: r.W, H: r.H})
}
masked := secretInk()
click(t, tt, "Show")
if equal(secretInk(), masked) {
t.Fatal("the eye did not reveal the password in the same frame")
}
click(t, tt, "Find")
query = "potion"
tt.Frame()
tt.Key(0, ui.KeyEscape)
r := find(t, tt, "Find")
shown := ink(t, tt.Image(), r)
tt.Frame()
if query != "" || !equal(shown, ink(t, tt.Image(), r)) {
t.Fatalf("Escape left %q, or showed the old text a frame", query)
}
}
func TestTextAreaFollowsTheCaret(t *testing.T) {
notes := ""
tt := view(400, 300, nil, func(c *ui.Context) {
input.TextArea(c, "Notes", &notes, input.TextAreaOptions{Lines: 2, MaxLength: 50})
pixel.Text(c, "F", pixel.TextOptions{})
})
click(t, tt, "Notes")
tt.Type("A\nB\nC\nD\nE\nF")
if _, ok := tt.Find("11/50"); !ok {
t.Fatalf("no character count in %q", tt.Texts())
}
area, ref := find(t, tt, "Notes"), find(t, tt, "F")
img := tt.Image()
glyph := ink(t, img, ui.Rect{X: ref.X, Y: ref.Y, W: ref.W, H: ref.H})
last := ink(t, img, ui.Rect{X: area.X, Y: area.Y + area.H - ref.H, W: ref.W, H: ref.H})
if !equal(glyph, last) {
t.Fatal("the caret's line F is not the bottom line in view")
}
}
func equal[T comparable](a, b []T) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestStepSliderStaysOnStepsWithinMax(t *testing.T) {
s := 0.0
tt := view(300, 100, nil, func(c *ui.Context) {
input.StepSlider(c, "odd", &s, input.StepSliderOptions{Min: 0, Max: 10, Step: 6})
})
click(t, tt, "odd")
tt.Key(0, ui.KeyEnd)
if s != 6 {
t.Fatalf("End went to %v, want the last step 6", s)
}
f := 0.0
tt = view(300, 100, nil, func(c *ui.Context) {
input.StepSlider(c, "tenths", &f, input.StepSliderOptions{Min: 0, Max: 0.3, Step: 0.1})
})
click(t, tt, "tenths")
tt.Key(0, ui.KeyEnd)
if math.Abs(f-0.3) > 1e-9 {
t.Fatalf("End over tenths went to %v, want 0.3", f)
}
}
func TestNumberInputRejectsNaNAndSyncsAtOnce(t *testing.T) {
v, changes := 5.0, 0
tt := view(300, 100, nil, func(c *ui.Context) {
if input.NumberInput(c, "N", &v, input.NumberInputOptions{Min: 0, Max: 10}).Changed {
changes++
}
})
r := find(t, tt, "N")
border := func() []pixel.Slot { return slots(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y - 6, W: r.W, H: 2}) }
field := func() []bool { return ink(t, tt.Image(), r) }
eight := 8.0
ref := view(300, 100, nil, func(c *ui.Context) {
input.NumberInput(c, "N", &eight, input.NumberInputOptions{Min: 0, Max: 10})
})
shows8 := func() bool { return equal(field(), ink(t, ref.Image(), r)) }
v = 8
tt.Frame()
if !shows8() {
t.Fatal("an outside change did not show at once")
}
click(t, tt, "N")
v = 3
tt.Frame()
eight = 3
ref.Frame()
click(t, ref, "N")
if !shows8() {
t.Fatal("a focused field ignored an outside change")
}
click(t, tt, "N")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("NaN")
if v != 3 || !has(border(), pixel.Bad) {
t.Fatalf("NaN set %v or left the edge plain", v)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("10")
changes = 0
tt.Key(0, ui.KeyUp)
if v != 10 || changes != 0 {
t.Fatalf("Up at the max gave %v with %d changes", v, changes)
}
}
func TestDisabledTextAreaHoldsStill(t *testing.T) {
notes, disabled := "A\nB\nC\nD\nE\nF", false
tt := view(400, 300, nil, func(c *ui.Context) {
input.TextArea(c, "Notes", &notes, input.TextAreaOptions{Lines: 2, Disabled: disabled})
})
disabled = true
tt.Frame()
r := find(t, tt, "Notes")
before := ink(t, tt.Image(), r)
// The caret's line keeps it at the bottom; wheel up.
tt.Scroll(r.X+10, r.Y+10, 0, -64)
if !equal(before, ink(t, tt.Image(), find(t, tt, "Notes"))) {
t.Fatal("a disabled text area scrolled")
}
}