A cross-cutting pass, each fix with a test that failed first. Veils: MyGo still hands a key to a base that turned off this frame, so every element key now goes through pixel.Shortcut, which an archtest enforces. Input handlers record and the build applies them only while enabled; the editor turns away a veil-frame letter or copy; value bases get a copy and keep only enabled turns. Popups give a held focus back to their opener when the veil lifts. Motion: S5 is enforced at the clock. Every Tick, Since, Step, Toward and Shot runs at 4-15 fps; blinks keep 1 Hz on 4 fps steps, fast animations step at 15, and drawn time is quantized to frames. Clocks stepping back: Since holds at zero without overflow, and k.Elapsed moves a stored start to now, so nothing goes negative, runs backward or indexes out of range. Also: logs count typed text instead of holding it, gallery names use glyphs the font has, removable rows are keyed, components take their own Options (archtest), and sweeps over all 508 demos check veiled keys, clicks, wheels, the clock stepping back and names.
331 lines
9.9 KiB
Go
331 lines
9.9 KiB
Go
package display_test
|
|
|
|
import (
|
|
"image"
|
|
"image/color"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
type clock struct{ t time.Time }
|
|
|
|
func (c *clock) now() time.Time { return c.t }
|
|
|
|
func newClock() *clock { return &clock{time.Unix(2_000_000, 0)} }
|
|
|
|
type env struct {
|
|
pal *pixel.Palette
|
|
locale pixel.Locale
|
|
clk *clock
|
|
}
|
|
|
|
func view(w, h int, e *env, build func(c *ui.Context)) *ui.Tester {
|
|
return ui.NewTester(func(c *ui.Context) {
|
|
pixel.Use(c, pixel.Settings{Palette: e.pal, Locale: e.locale, Now: e.clk.now})
|
|
build(c)
|
|
}, w, h)
|
|
}
|
|
|
|
func spring() *env { return &env{pixel.SpringDay, pixel.En, newClock()} }
|
|
|
|
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
|
|
}
|
|
|
|
// slots maps a box's dots to palette slots, or fails.
|
|
func slots(t *testing.T, img *image.RGBA, pal *pixel.Palette, 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 pal.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 %s: %v", x, y, pal.Name, c)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func has(s []pixel.Slot, want pixel.Slot) bool {
|
|
for _, v := range s {
|
|
if v == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestIconLabels(t *testing.T) {
|
|
tt := view(200, 100, spring(), func(c *ui.Context) {
|
|
display.Icon(c, icon.Heart, display.IconOptions{Scale: 3, Label: "heart"})
|
|
display.Icon(c, icon.Star, display.IconOptions{})
|
|
})
|
|
if r := find(t, tt, "heart"); r.W != float32(icon.Heart.W*3*2) {
|
|
t.Fatalf("icon box %v", r)
|
|
}
|
|
if len(tt.Texts()) != 1 {
|
|
t.Fatalf("an unlabeled icon reached assistive technology: %q", tt.Texts())
|
|
}
|
|
}
|
|
|
|
func TestSpritePlaysAndMirrors(t *testing.T) {
|
|
e := spring()
|
|
sp := pixel.MustSprite("r.\n..\n\n.r\n..")
|
|
tt := view(100, 100, e, func(c *ui.Context) {
|
|
ui.Column(c).Children(func() {
|
|
display.Sprite(c, sp, display.SpriteOptions{FPS: 4, Label: "s"})
|
|
display.Sprite(c, sp, display.SpriteOptions{Mirror: true, Label: "m"})
|
|
})
|
|
})
|
|
at := func(label string) []pixel.Slot { return slots(t, tt.Image(), e.pal, find(t, tt, label)) }
|
|
f0 := at("s")
|
|
if f0[0] != pixel.Bad || at("m")[1] != pixel.Bad {
|
|
t.Fatalf("frame 0 %v, mirrored %v", f0, at("m"))
|
|
}
|
|
e.clk.t = e.clk.t.Add(time.Second / 4)
|
|
tt.Frame()
|
|
if at("s")[1] != pixel.Bad {
|
|
t.Fatal("the sprite did not play its second frame")
|
|
}
|
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true})
|
|
if at("s")[0] != pixel.Bad {
|
|
t.Fatal("Reduce Motion did not hold the first frame")
|
|
}
|
|
}
|
|
|
|
func TestPictureRecolors(t *testing.T) {
|
|
e := spring()
|
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
|
for i := range img.Pix {
|
|
img.Pix[i] = 200
|
|
}
|
|
tt := view(100, 100, e, func(c *ui.Context) { display.Picture(c, img, display.PictureOptions{Label: "pic"}) })
|
|
day := slots(t, tt.Image(), e.pal, find(t, tt, "pic"))
|
|
e.pal = pixel.WinterNight
|
|
tt.Frame()
|
|
night := slots(t, tt.Image(), e.pal, find(t, tt, "pic"))
|
|
// Quantized afresh, a gray lands on other slots at night.
|
|
if slices.Equal(day, night) {
|
|
t.Fatalf("the picture kept its day slots at night: %v", night[:4])
|
|
}
|
|
}
|
|
|
|
// listImage holds a slice, so its values do not compare.
|
|
type listImage struct{ px []color.RGBA }
|
|
|
|
func (l listImage) ColorModel() color.Model { return color.RGBAModel }
|
|
func (l listImage) Bounds() image.Rectangle { return image.Rect(0, 0, 4, 4) }
|
|
func (l listImage) At(x, y int) color.Color { return l.px[y*4+x] }
|
|
|
|
func TestPictureFollowsEditsAndAnyImage(t *testing.T) {
|
|
e := spring()
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
list := listImage{make([]color.RGBA, 16)}
|
|
for i := range list.px {
|
|
list.px[i] = color.RGBA{0, 0, 0, 255}
|
|
}
|
|
tt := view(200, 100, e, func(c *ui.Context) {
|
|
display.Picture(c, img, display.PictureOptions{Label: "rgba"})
|
|
display.Picture(c, list, display.PictureOptions{Label: "list"})
|
|
})
|
|
before := slots(t, tt.Image(), e.pal, find(t, tt, "rgba"))
|
|
for i := range img.Pix {
|
|
img.Pix[i] = 255
|
|
}
|
|
tt.Frame()
|
|
if slices.Equal(before, slots(t, tt.Image(), e.pal, find(t, tt, "rgba"))) {
|
|
t.Fatal("editing the image in place did not redraw it")
|
|
}
|
|
if got := slots(t, tt.Image(), e.pal, find(t, tt, "list")); got[0] != pixel.Ink {
|
|
t.Fatalf("a black list image drew %v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestBadgeAndTrophyTakeAnyText(t *testing.T) {
|
|
tt := view(400, 200, spring(), func(c *ui.Context) {
|
|
display.Badge(c, 0, display.BadgeOptions{Text: "新"})
|
|
display.Badge(c, 7, display.BadgeOptions{})
|
|
display.Trophy(c, "冠军 É", display.TrophyOptions{})
|
|
})
|
|
e := spring()
|
|
if cjk, digit := find(t, tt, "新"), find(t, tt, "7"); cjk.H <= digit.H {
|
|
t.Fatalf("the CJK badge %v is no taller than the micro one %v", cjk, digit)
|
|
}
|
|
if !has(slots(t, tt.Image(), e.pal, find(t, tt, "新")), pixel.AccentInk) {
|
|
t.Fatal("the CJK badge drew no text")
|
|
}
|
|
// The cup has no Ink; only the plaque's words do.
|
|
if !has(slots(t, tt.Image(), e.pal, find(t, tt, "冠军 É")), pixel.Ink) {
|
|
t.Fatal("the trophy plaque drew no text")
|
|
}
|
|
}
|
|
|
|
func TestSignpostIsOneTabStop(t *testing.T) {
|
|
tt := view(300, 300, spring(), func(c *ui.Context) {
|
|
display.Signpost(c, []string{"Town", "Cave", "Shop"}, display.SignpostOptions{})
|
|
ui.ButtonBase(c).Label("after").Size(20, 20)
|
|
})
|
|
tt.Key(0, ui.KeyTab)
|
|
if !tt.Focused("Town") {
|
|
t.Fatal("Tab did not enter at the first board")
|
|
}
|
|
tt.Key(0, ui.KeyDown)
|
|
if !tt.Focused("Cave") {
|
|
t.Fatal("Down did not move to the next board")
|
|
}
|
|
tt.Key(0, ui.KeyTab)
|
|
if !tt.Focused("after") {
|
|
t.Fatal("Tab did not leave the signpost")
|
|
}
|
|
}
|
|
|
|
func TestBadgeTagStatus(t *testing.T) {
|
|
e := spring()
|
|
removed, disabled := 0, false
|
|
tt := view(400, 200, e, func(c *ui.Context) {
|
|
display.Badge(c, 128, display.BadgeOptions{})
|
|
display.Badge(c, 5, display.BadgeOptions{Text: "NEW"})
|
|
if display.Tag(c, "fire", display.TagOptions{Removable: true, Disabled: disabled}).Removed {
|
|
removed++
|
|
}
|
|
display.StatusDot(c, display.Online, display.StatusDotOptions{Live: true})
|
|
})
|
|
find(t, tt, "99+")
|
|
find(t, tt, "NEW")
|
|
if err := tt.Click("REMOVE fire"); err != nil || removed != 1 {
|
|
t.Fatalf("remove: %v, %d", err, removed)
|
|
}
|
|
disabled = true
|
|
tt.Frame()
|
|
_ = tt.Click("REMOVE fire")
|
|
if removed != 1 {
|
|
t.Fatal("a disabled tag was removed")
|
|
}
|
|
dot := func() []pixel.Slot { return slots(t, tt.Image(), e.pal, find(t, tt, "ONLINE")) }
|
|
if !has(dot(), pixel.Good) {
|
|
t.Fatal("step 0 hid the online light")
|
|
}
|
|
e.clk.t = e.clk.t.Add(500 * time.Millisecond)
|
|
tt.Frame()
|
|
if has(dot(), pixel.Good) {
|
|
t.Fatal("a live online dot did not blink")
|
|
}
|
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true})
|
|
if !has(dot(), pixel.Good) {
|
|
t.Fatal("Reduce Motion left the light off")
|
|
}
|
|
e.locale = pixel.Zh
|
|
tt.Frame()
|
|
find(t, tt, "在线")
|
|
}
|
|
|
|
func TestSignpostBoards(t *testing.T) {
|
|
clicked, disabled := -1, false
|
|
tt := view(300, 300, spring(), func(c *ui.Context) {
|
|
if r := display.Signpost(c, []string{"Town", "Cave", "Shop"}, display.SignpostOptions{Disabled: disabled}); r.Clicked >= 0 {
|
|
clicked = r.Clicked
|
|
}
|
|
})
|
|
if err := tt.Click("Cave"); err != nil || clicked != 1 {
|
|
t.Fatalf("clicked %d, %v", clicked, err)
|
|
}
|
|
disabled, clicked = true, -1
|
|
tt.Frame()
|
|
_ = tt.Click("Shop")
|
|
if clicked != -1 {
|
|
t.Fatal("a disabled board clicked")
|
|
}
|
|
for _, n := range []int{0, 4} {
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%d boards did not panic", n)
|
|
}
|
|
}()
|
|
view(100, 100, spring(), func(c *ui.Context) { display.Signpost(c, make([]string, n), display.SignpostOptions{}) })
|
|
}()
|
|
}
|
|
}
|
|
|
|
func TestQRCodeQuietZoneAndFinder(t *testing.T) {
|
|
e := spring()
|
|
tt := view(400, 400, e, func(c *ui.Context) { display.QRCode(c, "HELLO", display.QRCodeOptions{Scale: 1}) })
|
|
r := find(t, tt, "HELLO")
|
|
s := slots(t, tt.Image(), e.pal, r)
|
|
n := int(r.W / 2)
|
|
if n != 21+8 {
|
|
t.Fatalf("version 1 code with quiet zone is %d modules, want 29", n)
|
|
}
|
|
at := func(x, y int) pixel.Slot { return s[y*n+x] }
|
|
for i := range 4 {
|
|
if at(i, i) != pixel.AccentInk || at(n-1-i, i) != pixel.AccentInk {
|
|
t.Fatal("the quiet zone is not light")
|
|
}
|
|
}
|
|
for i := range 7 {
|
|
if at(4+i, 4) != pixel.Ink || at(4, 4+i) != pixel.Ink || at(4+i, 10) != pixel.Ink {
|
|
t.Fatal("the top-left finder is not a dark ring")
|
|
}
|
|
}
|
|
if at(5, 5) != pixel.AccentInk || at(7, 7) != pixel.Ink {
|
|
t.Fatal("the finder's gap or center is wrong")
|
|
}
|
|
}
|
|
|
|
func TestBubbleTailMovesContent(t *testing.T) {
|
|
side := display.Bottom
|
|
tt := view(300, 200, spring(), func(c *ui.Context) {
|
|
display.Bubble(c, display.BubbleOptions{Tail: side}, func() { pixel.Text(c, "HI", pixel.TextOptions{}) })
|
|
})
|
|
below := find(t, tt, "HI")
|
|
side = display.Top
|
|
tt.Frame()
|
|
if above := find(t, tt, "HI"); above.Y != below.Y+5*2 {
|
|
t.Fatalf("a top tail put the text at %v, bottom at %v", above.Y, below.Y)
|
|
}
|
|
side = display.Left
|
|
tt.Frame()
|
|
if left := find(t, tt, "HI"); left.X != below.X+5*2 {
|
|
t.Fatalf("a left tail put the text at %v", left.X)
|
|
}
|
|
}
|
|
|
|
func TestPanicsOnBadInput(t *testing.T) {
|
|
for name, build := range map[string]func(c *ui.Context){
|
|
"emoji": func(c *ui.Context) { display.Emoji(c, "nope", display.EmojiOptions{}) },
|
|
"status": func(c *ui.Context) { display.StatusDot(c, 9, display.StatusDotOptions{}) },
|
|
"medal": func(c *ui.Context) { display.Medal(c, 9, display.MedalOptions{}) },
|
|
"side": func(c *ui.Context) { display.Bubble(c, display.BubbleOptions{Tail: 9}, func() {}) },
|
|
"scale": func(c *ui.Context) { display.Icon(c, icon.Star, display.IconOptions{Scale: 5}) },
|
|
"qr long": func(c *ui.Context) { display.QRCode(c, string(make([]byte, 4000)), display.QRCodeOptions{}) },
|
|
} {
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%s did not panic", name)
|
|
}
|
|
}()
|
|
view(200, 200, spring(), build)
|
|
}()
|
|
}
|
|
}
|