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.
286 lines
7.5 KiB
Go
286 lines
7.5 KiB
Go
package account_test
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
type clock struct{ t time.Time }
|
|
|
|
func (c *clock) now() time.Time { return c.t }
|
|
|
|
// world is the settings a test view runs under.
|
|
type world struct {
|
|
clk *clock
|
|
reduce, inert bool
|
|
}
|
|
|
|
func newWorld() *world {
|
|
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}}
|
|
}
|
|
|
|
// view builds on a fake clock, two pixels a dot.
|
|
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
|
|
tt := ui.NewTester(func(c *ui.Context) {
|
|
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert})
|
|
build(c)
|
|
}, w, h)
|
|
if wd.reduce {
|
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
|
tt.Frame()
|
|
}
|
|
return tt
|
|
}
|
|
|
|
// later moves the clock on and builds a frame.
|
|
func later(tt *ui.Tester, wd *world, d time.Duration) {
|
|
wd.clk.t = wd.clk.t.Add(d)
|
|
tt.Frame()
|
|
}
|
|
|
|
func click(t *testing.T, tt *ui.Tester, label string) {
|
|
t.Helper()
|
|
if err := tt.Click(label); err != nil {
|
|
t.Fatalf("%v in %q", err, tt.Texts())
|
|
}
|
|
}
|
|
|
|
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
|
|
t.Helper()
|
|
r, ok := tt.Find(label)
|
|
if !ok {
|
|
t.Fatalf("no %q in %q", label, tt.Texts())
|
|
}
|
|
return r
|
|
}
|
|
|
|
// none is a color outside the palette.
|
|
const none pixel.Slot = 255
|
|
|
|
// at is the slot at dot x, y, or none.
|
|
func at(img *image.RGBA, x, y int) pixel.Slot {
|
|
c := img.RGBAAt(2*x, 2*y)
|
|
for s, want := range pixel.SpringDay.Colors {
|
|
if c.R == want.R && c.G == want.G && c.B == want.B {
|
|
return pixel.Slot(s)
|
|
}
|
|
}
|
|
return none
|
|
}
|
|
|
|
// count is how many dots in a box hold s.
|
|
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
|
|
n := 0
|
|
for j := y; j < y+h; j++ {
|
|
for i := x; i < x+w; i++ {
|
|
if at(img, i, j) == s {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// dots is a found box in dots.
|
|
func dots(r ui.Rect) (x, y, w, h int) {
|
|
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
|
|
}
|
|
|
|
func mustPanic(t *testing.T, name string, fn func()) {
|
|
t.Helper()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%s did not panic", name)
|
|
}
|
|
}()
|
|
fn()
|
|
}
|
|
|
|
// logged runs fn with the debug log captured.
|
|
func logged(fn func()) string {
|
|
var buf bytes.Buffer
|
|
old := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
defer slog.SetDefault(old)
|
|
fn()
|
|
return buf.String()
|
|
}
|
|
|
|
func TestLoginFormSubmitsAndShakesOnFailure(t *testing.T) {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := newWorld()
|
|
wd.reduce = reduce
|
|
name, pass, fails, sent := "", "", 0, 0
|
|
tt := view(500, 300, wd, func(c *ui.Context) {
|
|
if account.LoginForm(c, "WELCOME", &name, &pass, account.LoginFormOptions{Failures: fails}).Submitted {
|
|
sent++
|
|
}
|
|
})
|
|
click(t, tt, "LOGIN")
|
|
if sent != 0 {
|
|
t.Fatal("LOGIN sent an empty form")
|
|
}
|
|
log := logged(func() {
|
|
click(t, tt, "NAME")
|
|
tt.Type("player1")
|
|
click(t, tt, "PASSWORD")
|
|
tt.Type("hunter2")
|
|
tt.Key(0, ui.KeyEnter)
|
|
})
|
|
if sent != 1 || strings.Contains(log, "hunter2") || strings.Contains(log, "player1") {
|
|
t.Fatalf("Enter sent %d; the log must not hold credentials:\n%s", sent, log)
|
|
}
|
|
x0, _, _, _ := dots(find(t, tt, "LOGIN"))
|
|
// The caller says no: the password clears, focus comes back.
|
|
click(t, tt, "NAME")
|
|
fails++
|
|
tt.Frame()
|
|
if pass != "" || name != "player1" || !tt.HasText("WRONG NAME OR PASSWORD") || !tt.Focused("PASSWORD") {
|
|
t.Fatalf("after a failure: %q %q, %q", name, pass, tt.Texts())
|
|
}
|
|
x1, _, _, _ := dots(find(t, tt, "LOGIN"))
|
|
if moved := x1 != x0; moved == reduce {
|
|
t.Fatalf("reduce %v: the form moved %v", reduce, moved)
|
|
}
|
|
later(tt, wd, time.Second)
|
|
if x2, _, _, _ := dots(find(t, tt, "LOGIN")); x2 != x0 {
|
|
t.Fatalf("the shake did not settle: %d, was %d", x2, x0)
|
|
}
|
|
tt.Type("x")
|
|
if tt.HasText("WRONG NAME OR PASSWORD") {
|
|
t.Fatal("typing kept the warning up")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPasswordGridSpellsAndEnds(t *testing.T) {
|
|
wd := newWorld()
|
|
pass, ended := "", false
|
|
tt := view(500, 300, wd, func(c *ui.Context) {
|
|
ended = account.PasswordGrid(c, "Grid", &pass, account.PasswordGridOptions{Length: 4}).Submitted || ended
|
|
})
|
|
r := find(t, tt, "SYMBOLS")
|
|
cell := func(i int) (float32, float32) { return r.X + float32(22*(i%8)+11), r.Y + float32(22*(i/8)+11) }
|
|
log := logged(func() {
|
|
for _, i := range []int{9, 20, 18, 19} {
|
|
tt.ClickAt(cell(i))
|
|
}
|
|
})
|
|
if pass != "JUST" || strings.Contains(log, "JUST") {
|
|
t.Fatalf("spelled %q; log:\n%s", pass, log)
|
|
}
|
|
tt.ClickAt(cell(0))
|
|
if pass != "JUST" {
|
|
t.Fatal("a full password took another symbol")
|
|
}
|
|
tt.Key(0, ui.KeyBackspace)
|
|
click(t, tt, "BACK")
|
|
if pass != "JU" {
|
|
t.Fatalf("Backspace and BACK left %q", pass)
|
|
}
|
|
click(t, tt, "END")
|
|
if ended {
|
|
t.Fatal("END worked on a short password")
|
|
}
|
|
tt.ClickAt(cell(18))
|
|
tt.ClickAt(cell(19))
|
|
click(t, tt, "END")
|
|
if !ended {
|
|
t.Fatal("END on a full password did not end")
|
|
}
|
|
for name, p := range map[string]string{"lowercase": "ab", "too long": "ABCDE"} {
|
|
mustPanic(t, name, func() {
|
|
view(500, 300, newWorld(), func(c *ui.Context) {
|
|
account.PasswordGrid(c, "G", &p, account.PasswordGridOptions{Length: 4})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTwoFactorTakesDigitsAndPastes(t *testing.T) {
|
|
wd := newWorld()
|
|
code, sent := "", 0
|
|
tt := view(500, 300, wd, func(c *ui.Context) {
|
|
if account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{}).Submitted {
|
|
sent++
|
|
}
|
|
})
|
|
click(t, tt, "Code")
|
|
log := logged(func() { tt.Type("42a7") })
|
|
if code != "427" || strings.Contains(log, "427") {
|
|
t.Fatalf("typed %q; log:\n%s", code, log)
|
|
}
|
|
tt.Key(0, ui.KeyBackspace)
|
|
tt.SetClipboard("your code: 1-3-5-2-4-6, enjoy")
|
|
tt.Key(ui.Cmd, ui.KeyV)
|
|
// Paste fills the four boxes left, from 1352.
|
|
if code != "421352" || sent != 1 {
|
|
t.Fatalf("paste gave %q, sent %d", code, sent)
|
|
}
|
|
tt.Type("9")
|
|
if code != "421352" || sent != 1 {
|
|
t.Fatalf("a seventh digit went in: %q, %d", code, sent)
|
|
}
|
|
// Three seconds left in the period: the bar is Bad.
|
|
wd.clk.t = time.Unix(60*1000+27, 0)
|
|
tt.Frame()
|
|
x, y, w, _ := dots(find(t, tt, "TIME LEFT"))
|
|
if count(tt.Image(), x, y+2, w, 2, pixel.Bad) == 0 {
|
|
t.Fatal("the bar is not Bad with three seconds left")
|
|
}
|
|
mustPanic(t, "letters in the code", func() {
|
|
view(500, 300, newWorld(), func(c *ui.Context) {
|
|
s := "12a"
|
|
account.TwoFactor(c, "C", &s, account.TwoFactorOptions{})
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestSessionListSignsOut(t *testing.T) {
|
|
wd := newWorld()
|
|
out := -1
|
|
tt := view(600, 300, wd, func(c *ui.Context) {
|
|
if r := account.SessionList(c, "Devices", []account.Session{
|
|
{Device: "Laptop", Seen: wd.clk.t, Current: true}, {Device: "Phone", Seen: wd.clk.t.Add(-2 * time.Hour)},
|
|
}, account.SessionListOptions{}); r.SignedOut >= 0 {
|
|
out = r.SignedOut
|
|
}
|
|
})
|
|
find(t, tt, "THIS DEVICE")
|
|
if !tt.HasText("2 H AGO") {
|
|
t.Fatalf("no last-seen time in %q", tt.Texts())
|
|
}
|
|
click(t, tt, "SIGN OUT Phone")
|
|
if out != 1 {
|
|
t.Fatalf("SIGN OUT Phone signed out %d", out)
|
|
}
|
|
}
|
|
|
|
func TestFeedbackFormSendsOnceRated(t *testing.T) {
|
|
wd := newWorld()
|
|
rating, words, sent := 0, "", false
|
|
tt := view(500, 300, wd, func(c *ui.Context) {
|
|
sent = account.FeedbackForm(c, "Feedback", &rating, &words, account.FeedbackFormOptions{}).Sent || sent
|
|
})
|
|
click(t, tt, "MESSAGE")
|
|
tt.Type("more slimes")
|
|
click(t, tt, "SEND ▶")
|
|
if sent {
|
|
t.Fatal("SEND worked without a rating")
|
|
}
|
|
click(t, tt, "4/5")
|
|
log := logged(func() { click(t, tt, "SEND ▶") })
|
|
if !sent || rating == 0 || strings.Contains(log, "slimes") {
|
|
t.Fatalf("sent %v at %d stars; log:\n%s", sent, rating, log)
|
|
}
|
|
}
|