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.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 08:21:39 -04:00
1 parent 51af14709d
commit c745b082a9
38 files changed
+2788 -13

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
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})
})
})
}