Files
ZacharyZhang-NYandClaude Opus 5.5 03e1f91706 Upgrade MyGo to v0.5.5
Elements are now pass-scoped value handles: tests that read bounds
after a frame bind a ui.Handle. pixel tells frames apart by the build
pass's root, since the Tester clock no longer moves on its own.

Fix the regressions the upgrade brought:
- Clipboard reads in input handlers go through c.Services().
- MyGo notes an overlay's opener after building it, so AutoFocus
  inside lost it; pixel.AutoFocus focuses through a Handle, and
  archtest bans the raw call. Dialogs hand the focus back again.
- Elements around a press stay hovered: Tooltip tells a press-given
  focus by focus arriving under the pointer.
- The web host moves the Tester clock with real time, at most 500 ms
  a call, and gives components the real clock.

The gallery's source index also accepts CRLF checkouts.

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

348 lines
11 KiB
Go

package game_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestPressStartBlinksAndReports(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
pressed := 0
tt := view(300, 60, wd, func(c *ui.Context) {
if game.PressStart(c, game.PressStartOptions{}).Pressed {
pressed++
}
})
var ink []int
for range 2 {
ink = append(ink, count(t, tt.Image(), 0, 0, 100, 20, pixel.Ink))
wd.clk.t = wd.clk.t.Add(500 * time.Millisecond)
tt.Frame()
}
if blinked := (ink[0] == 0) != (ink[1] == 0); blinked == reduce {
t.Fatalf("reduced %v: PRESS START showed %v", reduce, ink)
}
tt.Click("PRESS START")
tt.Key(0, ui.KeyEnter)
if pressed != 2 {
t.Fatalf("a click and Enter pressed %d times", pressed)
}
}
}
func TestTitleScreenStartsThenChooses(t *testing.T) {
started, chosen := false, -1
tt := view(600, 300, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.TitleScreen(c, "SLIME QUEST", &started, game.TitleScreenOptions{}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
tt.Click("PRESS START")
if !started || tt.HasText("PRESS START") {
t.Fatalf("PRESS START left %q", tt.Texts())
}
// The menu takes focus; no save turns CONTINUE off.
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if chosen != -1 {
t.Fatalf("CONTINUE with no save chose %d", chosen)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if chosen != 2 {
t.Fatalf("OPTIONS chose %d", chosen)
}
tt.Key(0, ui.KeyEscape)
if started || !tt.HasText("PRESS START") {
t.Fatal("Escape did not go back to PRESS START")
}
keeps(t, tt.Image())
// Shown first thing, the menu still takes the keys.
again, picked := true, -1
tt = view(600, 300, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.TitleScreen(c, "SLIME QUEST", &again, game.TitleScreenOptions{}); r.Chosen >= 0 {
picked = r.Chosen
}
})
tt.Key(0, ui.KeyEnter)
if picked != 0 {
t.Fatalf("Enter on a fresh menu chose %d", picked)
}
}
func TestSaveSlotsChooseByClickAndKeys(t *testing.T) {
saves := []game.Save{{Place: "Woods", Played: 90 * time.Minute, Portrait: game.Hero}, {}, {Place: "Castle", Played: time.Minute}}
sel, chosen := 0, -1
tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.SaveSlots(c, "Saves", saves, &sel, game.SaveSlotsOptions{}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
r := find(t, tt, "Saves")
// Cards are 32 dots tall with a 3-dot gap.
tt.ClickAt(r.X+40, r.Y+float32(2*(2*35+10)))
if sel != 2 || chosen != 2 {
t.Fatalf("clicking card 3 chose %d, %d", sel, chosen)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
if chosen != 1 {
t.Fatalf("Up, Enter chose %d", chosen)
}
keeps(t, tt.Image())
// A long place name stays on its card.
long := []game.Save{{Place: "The Very Long Forest of Many Wobbly Slimes", Played: time.Hour, Portrait: game.Hero}}
first := 0
tt = view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.SaveSlots(c, "Long", long, &first, game.SaveSlotsOptions{})
})
if n := count(t, tt.Image(), 180, 0, 70, 40, pixel.Ink); n != 0 {
t.Fatalf("%d place dots past the card", n)
}
panics(t, "an empty save with play time", func() {
view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
game.SaveSlots(c, "x", []game.Save{{Played: time.Hour}}, &first, game.SaveSlotsOptions{})
})
})
}
func TestOptionsMenuStepsAndClamps(t *testing.T) {
speed, bgm, row, changes := 0, 9, 0, 0
tt := view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
if game.OptionsMenu(c, "Options", []game.Setting{{Name: "TEXT SPEED", Choices: []string{"SLOW", "FAST"}, Value: &speed}, {Name: "BGM", Max: 10, Value: &bgm}}, &row, game.OptionsMenuOptions{}).Changed {
changes++
}
})
// The focusing click lands on the second row.
tt.Click("Options")
for _, k := range []ui.Key{ui.KeyUp, ui.KeyLeft, ui.KeyRight, ui.KeyRight, ui.KeyDown, ui.KeyRight, ui.KeyRight, ui.KeyDown, ui.KeyLeft} {
tt.Key(0, k)
}
// SLOW stays, one step to FAST, BGM tops at 10.
if speed != 1 || bgm != 9 || row != 1 || changes != 3 {
t.Fatalf("speed %d, bgm %d, row %d, %d changes", speed, bgm, row, changes)
}
keeps(t, tt.Image())
panics(t, "a setting with both kinds", func() {
view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.OptionsMenu(c, "x", []game.Setting{{Name: "x", Choices: []string{"A"}, Max: 3, Value: &speed}}, &row, game.OptionsMenuOptions{})
})
})
}
func TestOptionsMenuKeysDoNotReplayClicks(t *testing.T) {
level, row := 5, 0
tt := view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.OptionsMenu(c, "Options", []game.Setting{{Name: "BGM", Max: 10, Value: &level}}, &row, game.OptionsMenuOptions{})
})
r := find(t, tt, "Options")
// ▶ follows the hand, name, ◀ and ten blocks.
bx := pixel.Hand.W + 4 + pixel.Measure("BGM") + 10 + pixel.Measure("◀") + 3 + 40 + 3
tt.ClickAt(r.X+float32(2*(bx+2)), r.Y+16)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if level != 6 {
t.Fatalf("a click on ▶, Enter and Space left BGM at %d", level)
}
}
func TestGameOverDithersInAndAnswers(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
quit := false
tt := view(600, 300, wd, func(c *ui.Context) {
quit = quit || game.GameOver(c, game.GameOverOptions{}).Quit
})
r := find(t, tt, "GAME OVER")
x, y := dots(r)
first := count(t, tt.Image(), x, y, int(r.W)/2, int(r.H)/2, pixel.Bad)
wd.clk.t = wd.clk.t.Add(3 * time.Second)
tt.Frame()
full := count(t, tt.Image(), x, y, int(r.W)/2, int(r.H)/2, pixel.Bad)
if full == 0 || (first == full) != reduce {
t.Fatalf("reduced %v: GAME OVER drew %d dots, then %d", reduce, first, full)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !quit {
t.Fatalf("reduced %v: Down, Enter did not quit", reduce)
}
}
}
func TestCreditsRollOnceAndAgainOnCue(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
cue, done := 0, 0
tt := view(400, 400, wd, func(c *ui.Context) {
if game.Credits(c, "Credits", []game.Credit{{Heading: "DIRECTOR", Names: []string{"Aria"}}, {Heading: "CAST", Names: []string{"A", "B", "C", "D", "E", "F"}}}, game.CreditsOptions{Cue: cue}).Done {
done++
}
})
// Rolling, it starts empty; reduced, it shows all ten lines.
if lit := count(t, tt.Image(), 0, 0, 160, 96, pixel.Warn) > 0; lit != reduce || (done == 1) != reduce {
t.Fatalf("reduced %v: first frame lit %v, done %d", reduce, lit, done)
}
if h := find(t, tt, "Credits").H; (h == 2*160) != reduce {
t.Fatalf("reduced %v: the window is %v px tall", reduce, h)
}
for range 3 {
wd.clk.t = wd.clk.t.Add(5 * time.Second)
tt.Frame()
}
if done != 1 || count(t, tt.Image(), 0, 0, 160, 96, pixel.Ink) == 0 {
t.Fatalf("reduced %v: done %d times; the last name not resting", reduce, done)
}
cue = 1
tt.Frame()
for range 3 {
wd.clk.t = wd.clk.t.Add(5 * time.Second)
tt.Frame()
}
if done != 2 {
t.Fatalf("reduced %v: a new cue rolled %d times in all", reduce, done)
}
// An old cue mid-roll rolls anew; reduced, both end.
cue = 2
tt.Frame()
cue = 1
tt.Frame()
for range 3 {
wd.clk.t = wd.clk.t.Add(5 * time.Second)
tt.Frame()
}
if want := map[bool]int{false: 3, true: 4}[reduce]; done != want {
t.Fatalf("reduced %v: going back to cue 1 rolled %d times in all", reduce, done)
}
}
}
func TestEndingCardDithersIn(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(400, 200, wd, func(c *ui.Context) { game.EndingCard(c, game.EndingCardOptions{}) })
first := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back)
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
half := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back)
wd.clk.t = wd.clk.t.Add(2 * time.Second)
tt.Frame()
if full := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back); !(first < half && half < full) {
t.Fatalf("THE END dots %d, %d, %d", first, half, full)
}
}
func TestEndingCardRefusesNoRoom(t *testing.T) {
panics(t, "a card narrower than THE END", func() {
view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.EndingCard(c, game.EndingCardOptions{Width: 40}) })
})
}
func TestLoadingTipsTurnEveryFourSeconds(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(500, 100, wd, func(c *ui.Context) {
game.LoadingTips(c, []string{"Tip one.", "Tip two."}, 0.5, game.LoadingTipsOptions{})
})
a := tt.HasText("TIP: Tip one.") || tt.HasText("TIP: Tip two.")
before := tt.HasText("TIP: Tip one.")
wd.clk.t = wd.clk.t.Add(4 * time.Second)
tt.Frame()
if !a || tt.HasText("TIP: Tip one.") == before {
t.Fatalf("tips did not turn: %q", tt.Texts())
}
}
func TestInteractPromptBobsOnceASecond(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(300, 200, wd, func(c *ui.Context) {
var target ui.Element
ui.Column(c).Padding(60).Children(func() { target = ui.Box(c).Size(20, 20) })
game.InteractPrompt(c, target, "OPEN", game.InteractPromptOptions{})
})
start := wd.clk.t
var ys []float32
for _, at := range []time.Duration{100, 350, 600} {
wd.clk.t = start.Add(at * time.Millisecond)
tt.Frame()
// The bottom edge bobs; the top may sit clipped.
r := find(t, tt, "A OPEN")
ys = append(ys, r.Y+r.H)
}
if ys[0] != ys[1] || ys[1] == ys[2] {
t.Fatalf("the prompt sat at %v at .1, .35 and .6 s", ys)
}
}
func TestHealthBarTrailStepsAtFifteen(t *testing.T) {
wd, hp := &world{clk: newClock()}, 100
tt := view(300, 100, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) })
hp = 50
tt.Frame()
wd.clk.t = wd.clk.t.Add(350 * time.Millisecond)
// Frames each 10 ms show two steps at most.
seen := map[string]bool{}
for range 6 {
wd.clk.t = wd.clk.t.Add(10 * time.Millisecond)
tt.Frame()
seen[string(tt.Image().Pix)] = true
}
if len(seen) > 2 {
t.Fatalf("the trail drew %d pictures in 60 ms", len(seen))
}
}
func TestDamageNumbersRiseInWholeFrames(t *testing.T) {
wd := &world{clk: newClock()}
hits := []game.Hit{{Value: 999, X: 60, Y: 60}}
tt := view(300, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Damage", &hits, game.DamageNumbersOptions{}) })
wd.clk.t = wd.clk.t.Add(500 * time.Millisecond)
tt.Frame()
seen := map[string]bool{}
for range 7 {
seen[string(tt.Image().Pix)] = true
wd.clk.t = wd.clk.t.Add(10 * time.Millisecond)
tt.Frame()
}
if len(seen) > 2 {
t.Fatalf("%d pictures in 60 ms", len(seen))
}
}
func TestSpinsAndHitsRestartWhenTheClockStepsBack(t *testing.T) {
wd := &world{clk: newClock()}
faces := []data.Item{{Sprite: icon.Star, Name: "Star"}, {Sprite: icon.Coin, Name: "Coin"}}
spin, done := 0, 0
tt := view(300, 100, wd, func(c *ui.Context) {
if game.GachaReel(c, "Reels", faces, [3]int{1, 1, 1}, spin, game.GachaReelOptions{}).Done {
done++
}
})
spin = 1
tt.Frame()
// An hour back, the spin times from now and lands.
wd.clk.t = wd.clk.t.Add(-time.Hour)
tt.Frame()
for range 30 {
wd.clk.t = wd.clk.t.Add(200 * time.Millisecond)
tt.Frame()
}
if done != 1 {
t.Fatalf("the reels landed %d times", done)
}
hits := []game.Hit{{Value: 7, X: 60, Y: 40}}
tt = view(300, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Damage", &hits, game.DamageNumbersOptions{}) })
fresh := string(tt.Image().Pix)
wd.clk.t = wd.clk.t.Add(-time.Second)
tt.Frame()
if string(tt.Image().Pix) != fresh {
t.Fatal("a hit moved as the clock stepped back")
}
}