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>
380 lines
13 KiB
Go
380 lines
13 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 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)
|
||
}
|
||
}
|