494 lines
14 KiB
Go
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", ¬es, 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", ¬es, 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")
|
|
}
|
|
}
|