Files
8bitMyGoUI/chat/chat_test.go
T
ZacharyZhang-NYandClaude Opus 5.5 03e1f91706 Upgrade MyGo to v0.5.5
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>
2026-10-17 01:31:08 -04:00

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")
}