Files
ZacharyZhang-NY c745b082a9 Add account components 386-405; Reduce setting, PinInput Cmd+V
Twenty account components: SettingsList, OptionCycler, KeyBindings,
ControllerMap, ProfileCard, CharacterCreator, LoginForm, PasswordGrid,
SessionList, LanguagePicker, PalettePicker, VolumeMixer,
DifficultySelect, AccessibilityOptions, AccountSwitcher, PlayerSelect,
TwoFactor, About, PatchNotes and FeedbackForm.

The components never check credentials, and no secret reaches the log:
LoginForm reports Submitted and the caller counts Failures; each new
failure clears the password, refocuses it, shakes the form and names no
field. TwoFactor builds on input.PinInput and drains an epoch-aligned
period bar. PalettePicker draws each tile in its own palette on a
pixel.Pad grid.

pixel.Settings.Reduce stills motion like the system preference, the
micro font gains '?', display.Face draws avatar faces anywhere, and
input.PinInput pastes on Cmd+V as native hosts send it.
2026-10-12 08:21:39 -04:00

305 lines
10 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 account_test
import (
"fmt"
"image"
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// offPalette counts pixels of a box no palette color matches.
func offPalette(img *image.RGBA, r ui.Rect) int {
n := 0
for y := int(r.Y); y < int(r.Y+r.H); y++ {
for x := int(r.X); x < int(r.X+r.W); x++ {
c, hit := img.RGBAAt(x, y), false
// PalettePicker shows every palette, so any will do.
for _, pal := range pixel.Palettes {
for _, p := range pal.Colors {
hit = hit || p.R == c.R && p.G == c.G && p.B == c.B
}
}
if !hit {
n++
}
}
}
return n
}
func TestPatchNotesReadInChinese(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Locale: pixel.Zh})
account.PatchNotes(c, "更新", []account.Patch{{Version: "v2", Added: []string{"船"}}}, account.PatchNotesOptions{})
}, 400, 200)
find(t, tt, "新增: 船")
}
func TestAccountStaysOnThePaletteWhenOff(t *testing.T) {
builds := map[string]func(c *ui.Context, off bool){
"access": func(c *ui.Context, off bool) {
a := account.Access{Dot: 2}
account.AccessibilityOptions(c, "X", &a, account.AccessibilityOptionsOptions{Disabled: off})
},
"creator": func(c *ui.Context, off bool) {
l := account.Look{}
account.CharacterCreator(c, "X", &l, account.CharacterCreatorOptions{Disabled: off})
},
"switcher": func(c *ui.Context, off bool) {
i := 0
account.AccountSwitcher(c, "X", [4]string{"Aria", "", "", ""}, &i, account.AccountSwitcherOptions{Disabled: off})
},
"feedback": func(c *ui.Context, off bool) {
r, m := 3, "hi"
account.FeedbackForm(c, "X", &r, &m, account.FeedbackFormOptions{Disabled: off})
},
"keys": func(c *ui.Context, off bool) {
account.KeyBindings(c, "X", []account.Binding{{Action: "Jump", Key: ui.KeyZ}}, account.KeyBindingsOptions{Disabled: off})
},
"difficulty": func(c *ui.Context, off bool) {
i := 1
account.DifficultySelect(c, "X", &i, account.DifficultySelectOptions{Disabled: off})
},
"login": func(c *ui.Context, off bool) {
n, pw := "a", "b"
account.LoginForm(c, "X", &n, &pw, account.LoginFormOptions{Disabled: off})
},
"cycler": func(c *ui.Context, off bool) {
i := 0
account.OptionCycler(c, "X", []string{"A", "B"}, &i, account.OptionCyclerOptions{Disabled: off})
},
"palettes": func(c *ui.Context, off bool) {
i := 0
account.PalettePicker(c, "X", &i, account.PalettePickerOptions{Disabled: off})
},
"players": func(c *ui.Context, off bool) {
i := 1
account.PlayerSelect(c, "X", &i, account.PlayerSelectOptions{Disabled: off})
},
"grid": func(c *ui.Context, off bool) {
pw := "AB"
account.PasswordGrid(c, "X", &pw, account.PasswordGridOptions{Disabled: off})
},
"sessions": func(c *ui.Context, off bool) {
account.SessionList(c, "X", []account.Session{{Device: "Phone", Seen: time.Unix(0, 0)}}, account.SessionListOptions{Disabled: off})
},
"mixer": func(c *ui.Context, off bool) {
m := account.Mix{BGM: 0.5}
account.VolumeMixer(c, "X", &m, account.VolumeMixerOptions{Disabled: off})
},
"twofactor": func(c *ui.Context, off bool) {
code := "12"
account.TwoFactor(c, "X", &code, account.TwoFactorOptions{Disabled: off})
},
}
for name, build := range builds {
for _, inert := range []bool{false, true} {
wd := newWorld()
wd.inert = inert
tt := view(700, 400, wd, func(c *ui.Context) { build(c, !inert) })
if n := offPalette(tt.Image(), ui.Rect{W: 700, H: 400}); n != 0 {
t.Errorf("%s, inert %v: %d pixels off the palette", name, inert, n)
}
}
}
}
func TestLoginFormLeavesFocusWhenOff(t *testing.T) {
wd := newWorld()
name, pass, fails := "a", "b", 0
tt := view(500, 300, wd, func(c *ui.Context) {
ui.Column(c).Children(func() {
ui.ButtonBase(c).Label("elsewhere").Size(40, 20)
account.LoginForm(c, "WELCOME", &name, &pass, account.LoginFormOptions{Failures: fails})
})
})
click(t, tt, "elsewhere")
// A failure lands in the frame Inert begins.
fails, wd.inert = 1, true
tt.Frame()
if tt.Focused("PASSWORD") {
t.Fatal("an inert form took the focus")
}
}
func TestOptionCyclerKeysIgnoreThePointer(t *testing.T) {
sel := 0
tt := view(400, 200, newWorld(), func(c *ui.Context) {
account.OptionCycler(c, "Speed", []string{"A", "B", "C"}, &sel, account.OptionCyclerOptions{})
})
r := find(t, tt, "Speed")
tt.Move(r.X+2, r.Y+r.H/2)
tt.Key(0, ui.KeyTab)
for range 2 {
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
}
if sel != 1 {
t.Fatalf("four presses with the pointer on the left gave %d, want 1", sel)
}
}
func TestKeyBindingsTakeAnyNextKey(t *testing.T) {
binds := []account.Binding{{Action: "Help", Key: ui.KeyH}}
tt := view(400, 200, newWorld(), func(c *ui.Context) {
account.KeyBindings(c, "Keys", binds, account.KeyBindingsOptions{})
})
click(t, tt, "Keys")
for _, k := range []ui.Key{ui.KeyF1, ui.KeySlash, ui.KeyPageDown} {
tt.Key(0, k)
if binds[0].Key != k {
t.Fatalf("the next key, %v, did not bind: %v", k, binds[0].Key)
}
tt.Key(0, ui.KeyEnter)
}
}
func TestTwoFactorTakesASCIIDigitsOnly(t *testing.T) {
mustPanic(t, "fullwidth digits", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
s := "12"
account.TwoFactor(c, "C", &s, account.TwoFactorOptions{})
})
})
}
func TestTwoFactorBarFollowsAnOddPeriod(t *testing.T) {
wd := newWorld()
// At 30 s, 1.1 s periods leave 0.8 s.
wd.clk.t = time.Unix(30, 0)
code := ""
tt := view(400, 200, wd, func(c *ui.Context) {
account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{Period: 1100 * time.Millisecond})
})
x, y, w, _ := dots(find(t, tt, "TIME LEFT"))
n := count(tt.Image(), x, y+2, w, 1, pixel.Bad)
if want := (w - 4) * 8 / 11; n < want-1 || n > want+1 {
t.Fatalf("the bar is %d dots, want %d", n, want)
}
}
func TestProfileCardWantsMicroStatNames(t *testing.T) {
mustPanic(t, "a stat named in hanzi", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: []account.Stat{{Name: "体力", Value: 1, Max: 2}}}, account.ProfileCardOptions{})
})
})
}
func TestKeyBindingsBindSpaceAfterEnter(t *testing.T) {
binds := []account.Binding{{Action: "Jump", Key: ui.KeyZ}}
tt := view(400, 200, newWorld(), func(c *ui.Context) {
account.KeyBindings(c, "Keys", binds, account.KeyBindingsOptions{})
})
click(t, tt, "Keys")
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if binds[0].Key != ui.KeySpace {
t.Fatalf("Enter, then Space bound %v", binds[0].Key)
}
}
func TestTwoFactorTakesTextAndThePasteCommand(t *testing.T) {
code, sent := "", 0
tt := view(400, 200, newWorld(), func(c *ui.Context) {
if account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{}).Submitted {
sent++
}
})
click(t, tt, "Code")
tt.Type("12")
// Browsers paste through the command, as cmd/web sends it.
tt.SetClipboard("a3b4c5d6e7")
tt.Command("paste")
if _, ok := tt.TextCaret(); !ok || code != "123456" || sent != 1 {
t.Fatalf("typing and the paste command gave %q, sent %d", code, sent)
}
}
func TestPalettePickerArrowsMoveByRows(t *testing.T) {
chosen := 0
tt := view(600, 300, newWorld(), func(c *ui.Context) {
account.PalettePicker(c, "Palettes", &chosen, account.PalettePickerOptions{})
})
tt.Key(0, ui.KeyTab)
got := []int{}
for _, k := range []ui.Key{ui.KeyDown, ui.KeyRight, ui.KeyUp} {
tt.Key(0, k)
tt.Key(0, ui.KeyEnter)
got = append(got, chosen)
}
if got[0] != 4 || got[1] != 5 || got[2] != 1 {
t.Fatalf("Down, Right, Up chose %v, want [4 5 1]", got)
}
}
func TestNegativeCountsPanic(t *testing.T) {
mustPanic(t, "a width of -1", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
i := 0
account.OptionCycler(c, "S", []string{"A"}, &i, account.OptionCyclerOptions{Width: -1})
})
})
mustPanic(t, "-1 failures", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
n, pw := "", ""
account.LoginForm(c, "L", &n, &pw, account.LoginFormOptions{Failures: -1})
})
})
}
func TestTwoFactorBarSurvivesALongPeriod(t *testing.T) {
wd := newWorld()
wd.clk.t = time.Unix(30, 0)
code := ""
tt := view(400, 200, wd, func(c *ui.Context) {
account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{Period: 4 * 365 * 24 * time.Hour})
})
x, y, w, _ := dots(find(t, tt, "TIME LEFT"))
if n := count(tt.Image(), x, y+2, w, 1, pixel.Good); n < w-5 {
t.Fatalf("a four-year period 30 s in fills %d of %d dots", n, w-4)
}
}
func TestProfileCardFillsAtHugeValues(t *testing.T) {
tt := view(1300, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: []account.Stat{{Name: "HP", Value: math.MaxInt, Max: math.MaxInt}}}, account.ProfileCardOptions{Width: 600})
})
x, y, w, _ := dots(find(t, tt, "HP"))
v := fmt.Sprintf("%d/%d", math.MaxInt, math.MaxInt)
inner := w - pixel.MicroWidth("HP") - pixel.MicroWidth(v) - 12
if n := count(tt.Image(), x, y+4, w, 1, pixel.Good); n != inner {
t.Fatalf("a full meter of huge numbers fills %d of %d dots", n, inner)
}
}
func TestProfileCardKeepsLongNamesOffTheBar(t *testing.T) {
stats := []account.Stat{{Name: "AGILITY", Value: 1, Max: 2}, {Name: "HP", Value: 12500, Max: 12500}}
tt := view(500, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: stats}, account.ProfileCardOptions{})
})
// Both bars start after AGILITY and end before 12500/12500.
nw, vw := pixel.MicroWidth("AGILITY"), pixel.MicroWidth("12500/12500")
for _, s := range stats {
x, y, w, _ := dots(find(t, tt, s.Name))
inner := w - nw - vw - 12
img := tt.Image()
if n := count(img, x, y+4, w, 1, pixel.Good); n != inner*s.Value/s.Max || at(img, x+nw+6, y+4) != pixel.Good {
t.Errorf("%s fills %d dots, want %d from dot %d", s.Name, n, inner*s.Value/s.Max, nw+6)
}
}
// Frames under 3×3 panic, so a card too narrow fails.
mustPanic(t, "no room for a bar", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: stats}, account.ProfileCardOptions{Width: 60})
})
})
}