Files
8bitMyGoUI/game/rewards_test.go
T
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

380 lines
13 KiB
Go
Raw 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 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 TestLetterboxSlidesInSteps(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
on := false
tt := view(300, 200, wd, func(c *ui.Context) {
game.Letterbox(c, on, game.LetterboxOptions{}, func() { ui.Box(c).Size(pixel.K(c).D(100), pixel.K(c).D(60)) })
})
bar := func() int { return count(t, tt.Image(), 50, 0, 1, 30, pixel.Ink) }
on = true
tt.Frame()
// Two of four steps in, a bar is 8 dots.
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
if half := bar(); (half == 8) == reduce || reduce && half != 16 {
t.Fatalf("reduced %v: a quarter second in, the bar is %d dots", reduce, half)
}
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
if bar() != 16 {
t.Fatalf("reduced %v: the bar settled at %d", reduce, bar())
}
// Turned off mid-slide, the bars return from there.
on = false
tt.Frame()
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
on = true
tt.Frame()
if now := bar(); !reduce && now != 8 || reduce && now != 16 {
t.Fatalf("reduced %v: turned back on at half, the bar is %d", reduce, now)
}
}
}
func TestMissionBriefStarts(t *testing.T) {
started := false
tt := view(500, 200, &world{clk: newClock()}, func(c *ui.Context) {
started = started || game.MissionBrief(c, game.Quest{Title: "Rescue", Objectives: []game.Objective{{Text: "Go", Need: 1}}}, []string{"####", "#..#", "####"}, game.Spot{X: 1, Y: 1}, nil, game.MissionBriefOptions{}).Start
})
tt.Click("START")
if !started {
t.Fatal("START did not report")
}
}
func TestAchievementsHideLockedNames(t *testing.T) {
awards := []game.Award{{Sprite: icon.Trophy, Name: "First blood", How: "Win a fight", Unlocked: true}, {Sprite: icon.Coin, Name: "King", How: "Beat the king"}}
sel := 0
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.Achievements(c, "Awards", awards, &sel, game.AchievementsOptions{})
})
if !tt.HasText("UNLOCKED 1/2") || !tt.HasText("First blood") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Click("Awards")
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyRight)
if sel != 1 || tt.HasText("King") || !tt.HasText("???") || !tt.HasText("Beat the king") {
t.Fatalf("a locked award showed %q", tt.Texts())
}
r := find(t, tt, "Awards")
x, y := dots(r)
// The locked coin is a Shade shadow: no Warn gold.
if count(t, tt.Image(), x+24, y, 24, 24, pixel.Warn) != 0 || count(t, tt.Image(), x+24, y, 24, 24, pixel.Shade) == 0 {
t.Fatal("the locked award is not a silhouette")
}
}
func TestResultScreenCountsThenRanks(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
tt := view(400, 200, wd, func(c *ui.Context) {
game.ResultScreen(c, "Result", 83*time.Second, 12500, "S", game.ResultScreenOptions{})
})
rank := func() int { return count(t, tt.Image(), 0, 0, 200, 100, pixel.Warn) }
if (rank() > 0) != reduce {
t.Fatalf("reduced %v: the rank showed before the counts", reduce)
}
wd.clk.t = wd.clk.t.Add(2 * time.Second)
tt.Frame()
if rank() == 0 {
t.Fatalf("reduced %v: no rank after the counts", reduce)
}
}
}
func TestResultScreenFitsABigScore(t *testing.T) {
tt := view(600, 200, &world{clk: newClock()}, func(c *ui.Context) {
ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() {
game.ResultScreen(c, "Result", 83*time.Second, 1000000000000000, "S", game.ResultScreenOptions{})
})
})
if n := count(t, tt.Image(), 0, 0, 20, 100, pixel.Ink); n != 0 {
t.Fatalf("%d score dots left of the screen", n)
}
if r := find(t, tt, "Result"); int(r.W)/2 < pixel.Measure("SCORE")+12+pixel.Measure("1000000000000000") {
t.Fatalf("the screen is %v px wide", r.W)
}
}
func TestCraftingShowsAKnownRecipe(t *testing.T) {
grid := make([]data.Item, 9)
grid[4] = data.Item{Sprite: icon.Leaf, Name: "Herb"}
recipes := []game.Recipe{{Grid: [9]string{4: "Herb"}, Result: data.Item{Sprite: icon.Potion, Name: "Potion"}}}
cell, crafted, picked := 0, 0, -1
tt := view(500, 200, &world{clk: newClock()}, func(c *ui.Context) {
r := game.Crafting(c, "Craft", grid, recipes, &cell, game.CraftingOptions{})
if r.Crafted {
crafted++
}
if r.Picked >= 0 {
picked = r.Picked
}
})
if !tt.HasText("Potion") {
t.Fatalf("no result shown: %q", tt.Texts())
}
tt.Click("CRAFT")
grid[4] = data.Item{}
tt.Frame()
tt.Click("CRAFT")
if crafted != 1 || tt.HasText("Potion") {
t.Fatalf("crafted %d times; texts %q", crafted, tt.Texts())
}
r := find(t, tt, "Craft")
tt.ClickAt(r.X+float32(2*(22*2+11)), r.Y+float32(2*(22*2+11)))
if cell != 8 || picked != 8 {
t.Fatalf("clicking the last cell picked %d", picked)
}
}
func TestLootPopupRisesAndCloses(t *testing.T) {
wd := &world{clk: newClock()}
open, closed := true, 0
tt := view(500, 300, wd, func(c *ui.Context) {
if game.LootPopup(c, data.Item{Sprite: icon.Potion, Name: "POTION", Count: 3}, &open, game.LootPopupOptions{}).Closed {
closed++
}
})
if !tt.HasText("YOU GOT: POTION ×3!") {
t.Fatalf("texts %q", tt.Texts())
}
r := find(t, tt, "POTION ×3")
x, y := dots(r)
top := func() int {
for j := range int(r.H) / 2 {
if count(t, tt.Image(), x, y+j, int(r.W)/2, 1, pixel.Bad) > 0 {
return j
}
}
return -1
}
low := top()
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
if rose := low - top(); rose != 8 {
t.Fatalf("the potion rose %d dots", rose)
}
tt.Click("OK")
if open || closed != 1 {
t.Fatal("OK did not close it")
}
}
func TestTreasureChestOpensOnce(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
open, opened := false, 0
tt := view(200, 200, wd, func(c *ui.Context) {
if game.TreasureChest(c, "Chest", data.Item{Sprite: icon.Gem, Name: "Gem"}, &open, game.TreasureChestOptions{}).Opened {
opened++
}
})
gem := func() int { return count(t, tt.Image(), 0, 0, 24, 21, pixel.Info) }
tt.Click("Chest")
if !open || opened != 1 || (gem() > 0) != reduce {
t.Fatalf("reduced %v: the press showed the gem %v", reduce, gem() > 0)
}
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
tt.Click("Chest")
if gem() == 0 || opened != 1 {
t.Fatalf("reduced %v: no gem, or opened twice", reduce)
}
}
}
func TestTreasureChestGemStaysInside(t *testing.T) {
open := true
tt := view(200, 200, &world{clk: newClock()}, func(c *ui.Context) {
ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() {
game.TreasureChest(c, "Chest", data.Item{Sprite: icon.Gem, Name: "Gem"}, &open, game.TreasureChestOptions{})
})
})
if n, in := count(t, tt.Image(), 0, 0, 100, 20, pixel.Info), count(t, tt.Image(), 20, 20, 24, 50, pixel.Info); n != 0 || in == 0 {
t.Fatalf("%d gem dots above the chest, %d in it", n, in)
}
}
func TestGachaReelSpinsAndStops(t *testing.T) {
faces := []data.Item{{Sprite: icon.Star, Name: "Star"}, {Sprite: icon.Heart, Name: "Heart"}, {Sprite: icon.Coin, Name: "Coin"}}
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
spin, done := 0, 0
tt := view(300, 100, wd, func(c *ui.Context) {
if game.GachaReel(c, "Reels", faces, [3]int{2, 2, 2}, spin, game.GachaReelOptions{}).Done {
done++
}
})
coins := func() int { return count(t, tt.Image(), 0, 0, 84, 28, pixel.Warn) }
still := coins()
spin = 1
tt.Frame()
var seen []int
for range 4 {
wd.clk.t = wd.clk.t.Add(100 * time.Millisecond)
tt.Frame()
seen = append(seen, coins())
}
spun := seen[0] != still || seen[1] != still || seen[2] != still
if spun == reduce || (done == 1) != reduce {
t.Fatalf("reduced %v: spun %v, done %d", reduce, spun, done)
}
wd.clk.t = wd.clk.t.Add(2 * time.Second)
tt.Frame()
if done != 1 || coins() != still {
t.Fatalf("reduced %v: done %d, coins %d of %d", reduce, done, coins(), still)
}
// Spinning back to an old cue lands; reduced, both do.
spin = 2
tt.Frame()
spin = 1
tt.Frame()
wd.clk.t = wd.clk.t.Add(2 * time.Second)
tt.Frame()
if want := map[bool]int{false: 2, true: 3}[reduce]; done != want {
t.Fatalf("reduced %v: spinning back to 1 landed %d times in all", reduce, done)
}
}
}
func TestGachaReelRefusesAFaceWithoutSprite(t *testing.T) {
panics(t, "a face with no sprite", func() {
view(300, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.GachaReel(c, "x", []data.Item{{Sprite: icon.Star, Name: "Star"}, {Name: "Blank"}}, [3]int{}, 0, game.GachaReelOptions{})
})
})
}
func TestInteractPromptSitsAboveItsTarget(t *testing.T) {
var target ui.Element
pad := 40
tt := view(400, 300, &world{clk: newClock(), reduce: true}, func(c *ui.Context) {
ui.Column(c).Padding(pixel.K(c).D(pad)).Children(func() {
target = ui.Box(c).Size(pixel.K(c).D(20), pixel.K(c).D(20)).Label("Mayor")
})
game.InteractPrompt(c, target, "TALK", game.InteractPromptOptions{})
})
// Right from the start, and after a move.
for range 2 {
bubble, mayor := find(t, tt, "A TALK"), find(t, tt, "Mayor")
if bubble.Y+bubble.H > mayor.Y || bubble.Y+bubble.H < mayor.Y-20 || bubble.X > mayor.X+mayor.W/2 {
t.Fatalf("the bubble %v does not sit above %v", bubble, mayor)
}
pad = 70
tt.Frame()
}
panics(t, "a two-letter key", func() {
view(300, 200, &world{clk: newClock()}, func(c *ui.Context) {
game.InteractPrompt(c, ui.Box(c), "TALK", game.InteractPromptOptions{Key: "AB"})
})
})
}
func TestTurnOrderRaisesTheActive(t *testing.T) {
tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.TurnOrder(c, "Turns", []game.Fighter{{Name: "Aria", Portrait: game.Hero}, {Name: "Slime", Portrait: icon.Skull, Foe: true}}, 1, game.TurnOrderOptions{})
})
img := tt.Image()
if at(t, img, 19+8, 0) != pixel.Accent || at(t, img, 8, 0) == pixel.Accent || at(t, img, 8, 5) != pixel.Good {
t.Fatal("the active portrait is not raised in Accent")
}
}
func TestBattleLogKeepsTheNewest(t *testing.T) {
lines := []game.Line{{Text: "one", Slot: pixel.Good}, {Text: "two"}, {Text: "three"}, {Text: "four"}, {Text: "five", Slot: pixel.Bad}}
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.BattleLog(c, "Log", lines, game.BattleLogOptions{}) })
// 68 dots hold four lines: the first rolled off.
if count(t, tt.Image(), 0, 0, 180, 68, pixel.Good) != 0 || count(t, tt.Image(), 0, 50, 180, 18, pixel.Bad) == 0 {
t.Fatal("the log does not keep the newest lines at the bottom")
}
// Two lines sit at the bottom, not the top.
lines = lines[3:]
lines[0].Slot = pixel.Good
tt.Frame()
if count(t, tt.Image(), 0, 34, 180, 16, pixel.Good) == 0 || count(t, tt.Image(), 0, 0, 180, 30, pixel.Good) != 0 {
t.Fatal("a short log is not at the bottom")
}
}
func TestTargetSelectorMovesTheCursor(t *testing.T) {
sel, chosen := 0, -1
enemies := []game.Enemy{{Name: "Slime", Sprite: icon.Skull, HP: 3, MaxHP: 9}, {Name: "Bat", Sprite: icon.Bug, HP: 6, MaxHP: 6}}
tt := view(300, 200, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.TargetSelector(c, "Targets", enemies, &sel, game.TargetSelectorOptions{}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
cursor := func(i int) int { return count(t, tt.Image(), i*40, 0, 40, 12, pixel.Accent) }
if cursor(0) == 0 || cursor(1) != 0 {
t.Fatal("the ▼ is not over the first target")
}
tt.Click("Targets")
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if sel != 1 || chosen != 1 || cursor(1) == 0 {
t.Fatalf("Right, Enter chose %d", chosen)
}
// A long name stays in its 40-dot cell.
enemies[0].Name = "Slime of the Very Long Name"
tt.Frame()
if n := count(t, tt.Image(), 80, 36, 60, 20, pixel.Ink); n != 0 {
t.Fatalf("%d name dots spilled past the cells", n)
}
}
func TestATBGaugeFlashesReady(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
v := 0.5
tt := view(200, 60, wd, func(c *ui.Context) { game.ATBGauge(c, "ARIA", v, game.ATBGaugeOptions{}) })
if count(t, tt.Image(), 0, 9, 64, 1, pixel.Info) != 30 {
t.Fatal("half a gauge is not 30 dots")
}
v = 1
tt.Frame()
var ready []int
for range 2 {
ready = append(ready, count(t, tt.Image(), 30, 0, 34, 6, pixel.Warn))
wd.clk.t = wd.clk.t.Add(125 * time.Millisecond)
tt.Frame()
}
if flashed := (ready[0] == 0) != (ready[1] == 0); flashed == reduce || ready[0]+ready[1] == 0 {
t.Fatalf("reduced %v: READY dots %v", reduce, ready)
}
}
zh := view(200, 60, &world{clk: newClock(), zh: true}, func(c *ui.Context) { game.ATBGauge(c, "ARIA", 1, game.ATBGaugeOptions{}) })
// 就绪 needs a 14-row label line above the 8-row bar.
if count(t, zh.Image(), 20, 0, 44, 14, pixel.Warn) == 0 || find(t, zh, "ARIA").H != 2*(14+8) {
t.Fatal("a Chinese READY is missing or crowds the bar")
}
panics(t, "a gauge too narrow", func() {
view(200, 60, &world{clk: newClock()}, func(c *ui.Context) { game.ATBGauge(c, "ARIA", 1, game.ATBGaugeOptions{Width: 10}) })
})
}
func TestStaminaBarDithersThePartBlock(t *testing.T) {
tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) { game.StaminaBar(c, "STAMINA", 0.65, game.StaminaBarOptions{}) })
img := tt.Image()
// Six blocks full, the seventh half, the rest empty.
if full, part, empty := count(t, img, 0, 7, 36, 6, pixel.Good), count(t, img, 36, 7, 5, 6, pixel.Good), count(t, img, 42, 7, 18, 6, pixel.Good); full != 180 || part == 0 || part == 30 || empty != 0 {
t.Fatalf("blocks: full %d, part %d, empty %d", full, part, empty)
}
}