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>
288 lines
8.3 KiB
Go
288 lines
8.3 KiB
Go
package chat_test
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chat"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
type clock struct{ t time.Time }
|
|
|
|
func (c *clock) now() time.Time { return c.t }
|
|
|
|
// newClock starts on Wednesday 2 September 2026, mid-morning.
|
|
func newClock() *clock { return &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)} }
|
|
|
|
// world is the settings a test view runs under.
|
|
type world struct {
|
|
clk *clock
|
|
reduce, zh, inert bool
|
|
}
|
|
|
|
// 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) {
|
|
s := pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert}
|
|
if wd.zh {
|
|
s.Locale = pixel.Zh
|
|
}
|
|
pixel.Use(c, s)
|
|
build(c)
|
|
}, w, h)
|
|
if wd.reduce {
|
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
|
tt.Frame()
|
|
}
|
|
return tt
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// count is how many dots of a box hold s.
|
|
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
|
|
n, want := 0, pixel.SpringDay.Colors[s]
|
|
for j := y; j < y+h; j++ {
|
|
for i := x; i < x+w; i++ {
|
|
if c := img.RGBAAt(2*i, 2*j); c.R == want.R && c.G == want.G && c.B == want.B {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func mustPanic(t *testing.T, name string, fn func()) {
|
|
t.Helper()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%s did not panic", name)
|
|
}
|
|
}()
|
|
fn()
|
|
}
|
|
|
|
func TestSpeechBubbleSidesNameAndTime(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
at := wd.clk.t
|
|
tt := view(300, 200, wd, func(c *ui.Context) {
|
|
ui.Column(c).Width(pixel.K(c).D(140)).Children(func() {
|
|
chat.SpeechBubble(c, chat.Message{From: "Aria", Text: "Hi there", At: at}, chat.SpeechBubbleOptions{Width: 80})
|
|
chat.SpeechBubble(c, chat.Message{From: "You", Text: "Hello", At: at, Mine: true}, chat.SpeechBubbleOptions{Width: 80})
|
|
})
|
|
})
|
|
theirs, mine := find(t, tt, "Hi there"), find(t, tt, "Hello")
|
|
if theirs.X > 40 || mine.X < 140 {
|
|
t.Fatalf("theirs at %v, mine at %v: theirs sit left, yours right", theirs.X, mine.X)
|
|
}
|
|
find(t, tt, "Aria")
|
|
if !tt.HasText("10:30") {
|
|
t.Fatalf("no time in %q", tt.Texts())
|
|
}
|
|
// Yours fill Accent; theirs Lit.
|
|
img := tt.Image()
|
|
if count(img, int(mine.X/2), int(mine.Y/2), 20, 6, pixel.Accent) == 0 || count(img, int(theirs.X/2), int(theirs.Y/2), 20, 6, pixel.Accent) != 0 {
|
|
t.Fatal("only your bubble is Accent")
|
|
}
|
|
}
|
|
|
|
func TestChatLogFollowsItsEndOnlyWhileThere(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
n := 8
|
|
tt := view(400, 300, wd, func(c *ui.Context) {
|
|
chat.ChatLog(c, "Log", chat.ChatLogOptions{Width: 160, Height: 80}, func() {
|
|
for i := range n {
|
|
pixel.Text(c, "line "+strings.Repeat("I", i+1), pixel.TextOptions{})
|
|
}
|
|
})
|
|
})
|
|
// Find clips to the view; whole lines are 32 pixels.
|
|
shown := func(label string) bool {
|
|
r, l := find(t, tt, label), find(t, tt, "Log")
|
|
return r.H == 32 && r.Y >= l.Y && r.Y+r.H <= l.Y+l.H
|
|
}
|
|
if !shown("line IIIIIIII") {
|
|
t.Fatal("the log did not open at its end")
|
|
}
|
|
n++
|
|
tt.Frame()
|
|
if !shown("line IIIIIIIII") {
|
|
t.Fatal("at the end, the log did not follow a new line")
|
|
}
|
|
// Scrolled up, a new line leaves the view alone.
|
|
for range 4 {
|
|
click(t, tt, "SCROLL UP")
|
|
}
|
|
n++
|
|
tt.Frame()
|
|
if shown("line IIIIIIIIII") {
|
|
t.Fatal("scrolled up, the log jumped to a new line")
|
|
}
|
|
}
|
|
|
|
func TestChatLogKeepsFollowingThroughInert(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
n := 8
|
|
tt := view(400, 300, wd, func(c *ui.Context) {
|
|
chat.ChatLog(c, "Log", chat.ChatLogOptions{Width: 160, Height: 80}, func() {
|
|
for i := range n {
|
|
pixel.Text(c, "line "+strings.Repeat("I", i+1), pixel.TextOptions{})
|
|
}
|
|
})
|
|
})
|
|
shown := func(label string) bool {
|
|
r, l := find(t, tt, label), find(t, tt, "Log")
|
|
return r.H == 32 && r.Y >= l.Y && r.Y+r.H <= l.Y+l.H
|
|
}
|
|
// A line landing while inert shows once the log wakes.
|
|
wd.inert = true
|
|
n++
|
|
tt.Frame()
|
|
tt.Frame()
|
|
wd.inert = false
|
|
tt.Frame()
|
|
if !shown("line IIIIIIIII") {
|
|
t.Fatal("after Inert the log did not catch up with its end")
|
|
}
|
|
n++
|
|
tt.Frame()
|
|
if !shown("line IIIIIIIIII") {
|
|
t.Fatal("after Inert the log stopped following")
|
|
}
|
|
}
|
|
|
|
func TestFieldsLogChangesWithoutText(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
old := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
defer slog.SetDefault(old)
|
|
draft, mention := "", ""
|
|
tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
|
|
chat.Composer(c, "Message", &draft, chat.ComposerOptions{})
|
|
chat.MentionInput(c, "Mention", &mention, []string{"Aria"}, chat.MentionInputOptions{})
|
|
})
|
|
click(t, tt, "Message")
|
|
tt.Type("hi")
|
|
click(t, tt, "Mention")
|
|
tt.Type("yo")
|
|
out := buf.String()
|
|
for _, want := range []string{"composer changed", "mention input changed", "runes=2"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("no %q in the logs:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, "hi") || strings.Contains(out, "yo") {
|
|
t.Error("the logs carry what was typed")
|
|
}
|
|
}
|
|
|
|
func TestComposerSendsOnEnterOrSend(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
draft, sent := "", []string{}
|
|
tt := view(400, 200, wd, func(c *ui.Context) {
|
|
if r := chat.Composer(c, "Message", &draft, chat.ComposerOptions{}); r.Sent != "" {
|
|
sent = append(sent, r.Sent)
|
|
}
|
|
})
|
|
// Empty, SEND ▶ does nothing.
|
|
click(t, tt, "SEND ▶")
|
|
click(t, tt, "Message")
|
|
tt.Type("hi")
|
|
tt.Key(ui.Shift, ui.KeyEnter)
|
|
tt.Type("all")
|
|
tt.Key(0, ui.KeyEnter)
|
|
if len(sent) != 1 || sent[0] != "hi\nall" || draft != "" {
|
|
t.Fatalf("sent %q, draft %q", sent, draft)
|
|
}
|
|
tt.Type("bye")
|
|
click(t, tt, "SEND ▶")
|
|
if len(sent) != 2 || sent[1] != "bye" || draft != "" {
|
|
t.Fatalf("sent %q, draft %q", sent, draft)
|
|
}
|
|
// The field keeps the focus; next words land there.
|
|
tt.Type("again")
|
|
if draft != "again" {
|
|
t.Fatalf("after sending, typing gave %q", draft)
|
|
}
|
|
}
|
|
|
|
func TestTypingIndicatorHopsUnlessReduced(t *testing.T) {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := &world{clk: newClock(), reduce: reduce}
|
|
tt := view(200, 100, wd, func(c *ui.Context) { chat.TypingIndicator(c, "Aria", chat.TypingIndicatorOptions{}) })
|
|
r := find(t, tt, "Aria IS TYPING")
|
|
before := count(tt.Image(), int(r.X/2), int(r.Y/2), 17, 3, pixel.Faint)
|
|
moved := false
|
|
for range 4 {
|
|
wd.clk.t = wd.clk.t.Add(time.Second / 6)
|
|
tt.Frame()
|
|
moved = moved || count(tt.Image(), int(r.X/2), int(r.Y/2), 17, 3, pixel.Faint) != before
|
|
}
|
|
if moved == reduce {
|
|
t.Fatalf("reduce %v: dots hopped %v", reduce, moved)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDateSeparatorNamesTheDay(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
zh bool
|
|
days int
|
|
want string
|
|
}{{false, 0, "TODAY"}, {false, -1, "YESTERDAY"}, {false, -7, "WED 26 AUG"}, {true, -7, "8月26日 周三"}, {true, 0, "今天"}} {
|
|
wd := &world{clk: newClock(), zh: tc.zh}
|
|
day := wd.clk.t.AddDate(0, 0, tc.days)
|
|
tt := view(400, 100, wd, func(c *ui.Context) { chat.DateSeparator(c, day, chat.DateSeparatorOptions{}) })
|
|
find(t, tt, tc.want)
|
|
}
|
|
// At midnight today turns into yesterday by itself.
|
|
wd := &world{clk: newClock()}
|
|
wd.clk.t = time.Date(2026, 9, 2, 23, 59, 59, 0, time.UTC)
|
|
day := wd.clk.t
|
|
pixel.ResetDue()
|
|
tt := view(400, 100, wd, func(c *ui.Context) { chat.DateSeparator(c, day, chat.DateSeparatorOptions{}) })
|
|
if until := pixel.Due().Sub(tt.Now()); until <= 0 || until > 2*time.Second {
|
|
t.Fatalf("the next build is due in %v, not at midnight", until)
|
|
}
|
|
wd.clk.t = wd.clk.t.Add(2 * time.Second)
|
|
tt.Frame()
|
|
find(t, tt, "YESTERDAY")
|
|
mustPanic(t, "narrow", func() {
|
|
view(100, 100, wd, func(c *ui.Context) { chat.UnreadDivider(c, chat.UnreadDividerOptions{Width: 30}) })
|
|
})
|
|
}
|
|
|
|
func TestDividerAndSystemLines(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
tt := view(500, 100, wd, func(c *ui.Context) {
|
|
chat.UnreadDivider(c, chat.UnreadDividerOptions{})
|
|
chat.SystemMessage(c, "PLAYER 2 HAS JOINED", chat.SystemMessageOptions{})
|
|
})
|
|
r := find(t, tt, "NEW MESSAGES")
|
|
if count(tt.Image(), int(r.X/2), int(r.Y/2), 200, 16, pixel.Bad) < 100 {
|
|
t.Fatal("the unread rule is not Bad")
|
|
}
|
|
find(t, tt, "PLAYER 2 HAS JOINED")
|
|
}
|