Files
ZacharyZhang-NY b4b4d67b92 Audit keyboard, focus, veils, motion and clocks across all 500
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.
2026-10-12 16:03:04 -04:00

398 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package overlay_test
import (
"fmt"
"strings"
"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/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestDrawerSlidesInInSteps(t *testing.T) {
clk, open, closed := newClock(), false, 0
side := display.Right
tt := view(640, 400, clk, false, func(c *ui.Context) {
if input.Button(c, "MENU", input.ButtonOptions{}).Clicked {
open = true
}
if overlay.Drawer(c, "Menu", &open, overlay.DrawerOptions{Side: side, Size: 100}, func() {
pixel.Text(c, "Items", pixel.TextOptions{})
}).Closed {
closed++
}
})
click(t, tt, "MENU")
var xs []float32
for range 5 {
xs = append(xs, find(t, tt, "Items").X)
clk.t = clk.t.Add(70 * time.Millisecond)
tt.Frame()
}
// Four 50-pixel steps, then rest at 640-200+16.
want := []float32{456 + 200, 456 + 150, 456 + 100, 456 + 50, 456}
for i := range want {
if xs[i] != want[i] {
t.Fatalf("x by step %v, want %v", xs, want)
}
}
keeps(t, tt.Image())
tt.Key(0, ui.KeyEscape)
if open || closed != 1 {
t.Fatal("Escape left it open")
}
side, open = display.Left, true
tt.Frame()
clk.t = clk.t.Add(time.Second)
tt.Frame()
if x := find(t, tt, "Items").X; x != 16 {
t.Fatalf("a left drawer opened at %v", x)
}
}
func TestDrawerOpenedUnderReduceMotionNeverSlides(t *testing.T) {
clk, open := newClock(), false
tt := view(640, 400, clk, false, func(c *ui.Context) {
overlay.Drawer(c, "Menu", &open, overlay.DrawerOptions{Side: display.Right, Size: 100}, func() {
pixel.Text(c, "Items", pixel.TextOptions{})
})
})
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
open = true
// At rest the panel's text sits at 640-200+16.
for _, reduce := range []bool{true, false} {
tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
tt.Frame()
if x := find(t, tt, "Items").X; x != 456 {
t.Fatalf("reduce %v: the drawer's text is at %v", reduce, x)
}
}
}
func TestSheetClosesOnALongPull(t *testing.T) {
open, closed := true, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120}, func() {
pixel.Text(c, "Sound: ON", pixel.TextOptions{})
}).Closed {
closed++
}
})
// The bar tops the sheet, which is 240 pixels.
top := float32(400 - 240)
tt.Press(320, top+8)
tt.Move(320, top+40)
tt.Release(320, top+40)
if !open || find(t, tt, "Sound: ON").Y > top+40 {
t.Fatal("a short pull closed or moved the sheet")
}
tt.Press(320, top+8)
tt.Move(320, top+60)
tt.Move(320, top+120)
tt.Release(320, top+120)
if open || closed != 1 {
t.Fatalf("a long pull left open %v", open)
}
}
func TestLightboxTurnsWrapsAndCloses(t *testing.T) {
open, at, closed := true, 0, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.Lightbox(c, "Gallery", 3, &at, &open, overlay.LightboxOptions{}, func(i int) {
pixel.Text(c, []string{"Castle", "Cave", "Coast"}[i], pixel.TextOptions{})
}).Closed {
closed++
}
})
tt.Key(0, ui.KeyLeft)
if at != 2 || !shows(tt, "Coast") || !shows(tt, "3 / 3") {
t.Fatalf("Left from the first went to %d", at)
}
click(t, tt, "NEXT")
tt.Key(0, ui.KeyRight)
if at != 1 || !shows(tt, "Cave") {
t.Fatalf("▶, Right went to %d", at)
}
keeps(t, tt.Image())
click(t, tt, "CLOSE")
if open || closed != 1 {
t.Fatal("× did not close it")
}
}
func TestDialogueBoxTypesThenTurns(t *testing.T) {
clk, page, done := newClock(), 0, 0
pages := []string{"It is dangerous to go alone!", "Take this."}
tt := view(640, 400, clk, false, func(c *ui.Context) {
if overlay.DialogueBox(c, pages, &page, overlay.DialogueBoxOptions{Name: "OLD MAN", Portrait: icon.User}).Done {
done++
}
})
r := find(t, tt, "OLD MAN "+pages[0])
tt.ClickAt(r.X+4, r.Y+4)
if page != 0 {
t.Fatal("the first press turned the page before the text was out")
}
tt.Key(0, ui.KeyEnter)
if page != 1 || !shows(tt, "OLD MAN Take this.") {
t.Fatalf("Enter after the text left page %d", page)
}
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
tt.Key(0, ui.KeyEnter)
if done != 1 || !shows(tt, "OLD MAN") {
t.Fatalf("Enter on the last page: done %d", done)
}
keeps(t, tt.Image())
}
func TestDialogueBoxUnderReduceMotionTurnsAtOnce(t *testing.T) {
page := 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.DialogueBox(c, []string{"One.", "Two.", "Three."}, &page, overlay.DialogueBoxOptions{})
})
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
click(t, tt, "One.")
tt.Key(0, ui.KeyEnter)
if page != 2 {
t.Fatalf("two presses under Reduce Motion reached page %d", page)
}
}
func TestNotificationsExpireAndDismiss(t *testing.T) {
clk := newClock()
queue := []overlay.Notification{{Title: "Saved", Text: "Slot 1."}, {Icon: icon.Mail, Title: "Letter", Text: "From mom.", For: 10 * time.Second}}
var dismissed string
tt := view(640, 400, clk, false, func(c *ui.Context) {
if r := overlay.Notifications(c, &queue, overlay.NotificationsOptions{}); r.Dismissed != nil {
dismissed = r.Dismissed.Title
}
})
a, b := find(t, tt, "Saved"), find(t, tt, "Letter")
if a.X+a.W != 640-16 || b.Y <= a.Y {
t.Fatalf("cards at %v and %v", a, b)
}
clk.t = clk.t.Add(6 * time.Second)
tt.Frame()
if shows(tt, "Saved") || len(queue) != 1 {
t.Fatalf("the first card outlived five seconds: %d left", len(queue))
}
click(t, tt, "CLOSE")
if dismissed != "Letter" || len(queue) != 0 {
t.Fatalf("× dismissed %q", dismissed)
}
}
func TestKeyHintsDrawPadButtonsAndKeycaps(t *testing.T) {
tt := view(640, 100, newClock(), false, func(c *ui.Context) {
overlay.KeyHints(c, []overlay.KeyHint{{Key: "A", Action: "CONFIRM"}, {Key: "START", Action: "MENU"}}, overlay.KeyHintsOptions{})
})
r := find(t, tt, "A CONFIRM")
if s := slot(t, tt.Image(), int(r.X)+18, int(r.Y)+r2i(r.H/2)); s != pixel.Ink {
t.Fatalf("pad button is %v", s)
}
if !shows(tt, "START") || !shows(tt, "MENU") {
t.Fatalf("texts %q", tt.Texts())
}
keeps(t, tt.Image())
}
func r2i(f float32) int { return int(f) }
func TestTitleCardDithersInHoldsAndGoes(t *testing.T) {
clk, cue := newClock(), 0
tt := view(320, 200, clk, false, func(c *ui.Context) {
ui.Box(c).Size(300, 160).Children(func() {
overlay.TitleCard(c, "WORLD 1-1", overlay.TitleCardOptions{Cue: cue, Hold: time.Second})
})
})
area := ui.Rect{X: 0, Y: 0, W: 300, H: 160}
if n := count(t, tt.Image(), area, pixel.Ink); n != 0 || shows(tt, "WORLD 1-1") {
t.Fatal("the card showed before its cue")
}
cue = 1
tt.Frame()
var inks []int
for range 4 {
inks = append(inks, count(t, tt.Image(), area, pixel.Ink))
clk.t = clk.t.Add(time.Second / 8)
tt.Frame()
}
if !(inks[0] < inks[1] && inks[1] < inks[2] && inks[2] < inks[3]) || !shows(tt, "WORLD 1-1") {
t.Fatalf("ink by step %v", inks)
}
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
if shows(tt, "WORLD 1-1") || count(t, tt.Image(), area, pixel.Ink) != 0 {
t.Fatal("the card stayed past its hold")
}
}
func TestOffDialogueBoxStaysPut(t *testing.T) {
for _, inert := range []bool{false, true} {
page := 0
tt := view(640, 400, newClock(), inert, func(c *ui.Context) {
overlay.DialogueBox(c, []string{"One.", "Two."}, &page, overlay.DialogueBoxOptions{Disabled: !inert})
})
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
click(t, tt, "One.")
click(t, tt, "One.")
tt.Key(0, ui.KeyEnter)
if page != 0 {
t.Fatalf("inert %v: an off box turned to page %d", inert, page)
}
}
}
func TestSheetVanishesOnTheReleaseThatClosesIt(t *testing.T) {
open := true
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120}, func() {
pixel.Text(c, "Sound: ON", pixel.TextOptions{})
})
})
top := float32(400 - 240)
tt.Press(320, top+8)
tt.Move(320, top+120)
tt.Release(320, top+120)
if open || shows(tt, "Sound: ON") {
t.Fatal("the sheet stayed on screen after the pull closed it")
}
}
func TestPanelsWithoutControlsTakeTheFocus(t *testing.T) {
for _, kind := range []string{"drawer", "sheet", "dialog", "alert", "confirm", "choice", "pause", "prompt", "progress"} {
open, opened := false, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if input.Button(c, "OPEN", input.ButtonOptions{}).Clicked {
open = true
opened++
}
body := func() { pixel.Text(c, "Just words.", pixel.TextOptions{}) }
name := "Menu"
switch kind {
case "drawer":
overlay.Drawer(c, name, &open, overlay.DrawerOptions{Side: display.Left}, body)
case "sheet":
overlay.Sheet(c, name, &open, overlay.SheetOptions{}, body)
case "dialog":
overlay.Dialog(c, name, &open, overlay.DialogOptions{}, body)
case "alert":
overlay.AlertDialog(c, name, "Hi.", &open, overlay.AlertDialogOptions{Disabled: true})
case "confirm":
overlay.ConfirmDialog(c, "Hi.", &open, overlay.ConfirmDialogOptions{Disabled: true})
case "choice":
overlay.ChoicePrompt(c, name, []string{"A"}, &open, overlay.ChoicePromptOptions{Disabled: true})
case "pause":
overlay.PauseMenu(c, &open, overlay.PauseMenuOptions{Disabled: true})
case "prompt":
s := ""
overlay.PromptDialog(c, name, &s, &open, overlay.PromptDialogOptions{Disabled: true})
case "progress":
overlay.ProgressDialog(c, name, 0.5, &open, overlay.ProgressDialogOptions{Disabled: true})
}
})
click(t, tt, "OPEN")
tt.Key(0, ui.KeyEnter)
if opened != 1 || tt.Focused("OPEN") {
t.Fatalf("%s: Enter reached the opener behind it (%d opens)", kind, opened)
}
}
}
func TestOffSheetTakesNoPull(t *testing.T) {
for _, inert := range []bool{false, true} {
open := true
tt := view(640, 400, newClock(), inert, func(c *ui.Context) {
overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120, Disabled: !inert}, func() {})
})
top := float32(400 - 240)
tt.Press(320, top+8)
tt.Move(320, top+120)
tt.Release(320, top+120)
if !open {
t.Fatalf("inert %v: a pull closed an off sheet", inert)
}
}
}
func TestDisabledPanelsTakeNoClicks(t *testing.T) {
open, at, saving, next := true, 0, true, 0
queue := []overlay.Notification{{Title: "Saved", Text: "Slot 1."}}
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.Lightbox(c, "Gallery", 3, &at, &open, overlay.LightboxOptions{Disabled: true}, func(int) {})
})
tt.Key(0, ui.KeyRight)
click(t, tt, "NEXT")
click(t, tt, "CLOSE")
tt = view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.ProgressDialog(c, "Saving", 0.2, &saving, overlay.ProgressDialogOptions{Disabled: true})
})
click(t, tt, "CANCEL")
tt = view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
b := input.Button(c, "BAG", input.ButtonOptions{})
if overlay.Coachmark(c, b.Element, "Bag.", 1, 2, overlay.CoachmarkOptions{Disabled: true}).Next {
next++
}
})
overlay.Notifications(c, &queue, overlay.NotificationsOptions{Disabled: true})
})
click(t, tt, "NEXT ▶")
click(t, tt, "CLOSE")
if at != 0 || !open || !saving || next != 0 || len(queue) != 1 {
t.Fatalf("disabled: at %d open %v saving %v next %d queue %d", at, open, saving, next, len(queue))
}
}
func TestDialogueBoxKeepsItsSpeedOnS5Steps(t *testing.T) {
for _, wait := range []time.Duration{900 * time.Millisecond, 1050 * time.Millisecond} {
clk, page := newClock(), 0
// Thirty letters at the default thirty a second.
pages := []string{"ABCDEFGHIJKLMNOPQRSTUVWXYZ1234", "NEXT"}
tt := view(640, 400, clk, false, func(c *ui.Context) {
overlay.DialogueBox(c, pages, &page, overlay.DialogueBoxOptions{})
})
clk.t = clk.t.Add(wait)
tt.Frame()
r := find(t, tt, pages[0])
tt.ClickAt(r.X+4, r.Y+4)
// A press on typed-out text turns the page.
if turned := page == 1; turned != (wait > time.Second) {
t.Errorf("after %v a press left page %d", wait, page)
}
}
}
func TestPausedBlinksOnceASecond(t *testing.T) {
clk, paused := newClock(), true
tt := view(640, 400, clk, false, func(c *ui.Context) { overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}) })
var ink []int
for _, at := range []time.Duration{100, 350, 600} {
clk.t = time.Unix(3_000_000, 0).Add(at * time.Millisecond)
tt.Frame()
ink = append(ink, count(t, tt.Image(), find(t, tt, "PAUSED"), pixel.Back))
}
if ink[0] != ink[1] || ink[1] == ink[2] {
t.Fatalf("PAUSED ink at .1, .35 and .6 s: %v", ink)
}
}
func TestDialogueBoxSpeedHasACeiling(t *testing.T) {
defer func() {
if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "overlay: DialogueBox CPS") {
t.Fatalf("CPS 1001 gave %q", r)
}
}()
page := 0
view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.DialogueBox(c, []string{"HI"}, &page, overlay.DialogueBoxOptions{CPS: 1001})
})
}