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>
348 lines
11 KiB
Go
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")
|
|
}
|
|
}
|