Files
MujicaUI/toast_test.go
T
ZacharyZhang-NY 5d395ad789 Refactor: Toaster returns its wake time instead of storing it
toastQueue.wake kept state only tests read. Toaster now reports the
frame's wake time in an unexported ToasterResult field, and
TestToastExpiresAndHoverPauses asserts on the returned value.
2026-10-05 13:49:09 -04:00

209 lines
6.3 KiB
Go

package mujica
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func toastIDs(q *toastQueue, visible bool) []string {
var ids []string
for _, it := range q.items {
if it.visible == visible {
ids = append(ids, it.msg.ID)
}
}
return ids
}
func TestToastQueueLimitsVisible(t *testing.T) {
var q toastQueue
t0 := time.Unix(0, 0)
for _, id := range []string{"a", "b", "c", "d", "e"} {
q.push(ToastMessage{ID: id, Message: id}, t0)
}
if v, w := toastIDs(&q, true), toastIDs(&q, false); !slices.Equal(v, []string{"a", "b", "c"}) || !slices.Equal(w, []string{"d", "e"}) {
t.Fatalf("visible %v waiting %v", v, w)
}
// d starts its 5 s only when it shows.
q.expire(t0.Add(ToastDuration))
if next := q.settle(t0.Add(ToastDuration), nil); next != ToastDuration {
t.Fatalf("next wake %v", next)
}
if v := toastIDs(&q, true); !slices.Equal(v, []string{"d", "e"}) {
t.Fatalf("after expiry visible %v", v)
}
q.expire(t0.Add(2*ToastDuration - time.Millisecond))
if len(q.items) != 2 {
t.Fatalf("expired early: %v", toastIDs(&q, true))
}
q.expire(t0.Add(2 * ToastDuration))
if len(q.items) != 0 {
t.Fatalf("left %v", toastIDs(&q, true))
}
}
func TestToastQueueDedupesByID(t *testing.T) {
var q toastQueue
t0 := time.Unix(0, 0)
q.push(ToastMessage{ID: "sync", Message: "Syncing 1 file"}, t0)
q.push(ToastMessage{Message: "Saved"}, t0)
q.push(ToastMessage{ID: "sync", Message: "Syncing 2 files"}, t0.Add(4*time.Second))
q.push(ToastMessage{Message: "Saved"}, t0.Add(4*time.Second))
if len(q.items) != 2 || q.items[0].msg.Message != "Syncing 2 files" || q.items[1].msg.ID != "Saved" {
t.Fatalf("items %+v", q.items)
}
// The replaced toast restarted its time.
q.expire(t0.Add(6 * time.Second))
if len(q.items) != 2 {
t.Fatalf("replacement kept the old time: %d left", len(q.items))
}
}
func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
var q toastQueue
t0 := time.Unix(0, 0)
q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0)
q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0)
q.settle(t0.Add(4*time.Second), map[string]bool{"w": true})
q.expire(t0.Add(time.Hour))
if next := q.settle(t0.Add(time.Hour), map[string]bool{"w": true}); next != 0 {
t.Fatalf("paused toast still counts down: %v", next)
}
if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) {
t.Fatalf("visible %v", toastIDs(&q, true))
}
// Released: the wake-up is computed after resuming, in the same call.
if next := q.settle(t0.Add(time.Hour), nil); next != time.Second {
t.Fatalf("resumed toast left %v, want 1s", next)
}
q.expire(t0.Add(time.Hour + time.Second))
if !slices.Equal(toastIDs(&q, true), []string{"e"}) {
t.Fatalf("visible %v", toastIDs(&q, true))
}
}
func TestToastQueuePanics(t *testing.T) {
for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}, {Message: "x", Severity: 9}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", m)
}
}()
var q toastQueue
q.push(m, time.Now())
}()
}
}
func toastTester(m ToastMessage, res *ToasterResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Notify", ButtonOptions{}).Clicked() {
ShowToast(c, m)
}
if r := Toaster(c); r != (ToasterResult{}) {
*res = r
}
}, 600, 400)
}
func TestToastActionAndClose(t *testing.T) {
var res ToasterResult
tt := toastTester(ToastMessage{ID: "del", Message: "Note deleted", Action: "Undo"}, &res)
if err := tt.Click("Notify"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Note deleted") {
t.Fatal("toast not shown")
}
if err := tt.Click("Undo"); err != nil {
t.Fatal(err)
}
if res.Action != "del" || tt.HasText("Note deleted") {
t.Fatalf("after undo %+v, still shown %v", res, tt.HasText("Note deleted"))
}
res = ToasterResult{}
_ = tt.Click("Notify")
if err := tt.Click("Dismiss notification"); err != nil {
t.Fatal(err)
}
if res.Closed != "del" || tt.HasText("Note deleted") {
t.Fatalf("after close %+v", res)
}
}
func TestToastKeepsFocusAndEscapeCloses(t *testing.T) {
var res ToasterResult
tt := toastTester(ToastMessage{ID: "s", Message: "Saved"}, &res)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Saved") || !tt.Focused("Notify") {
t.Fatalf("shown %v, focus kept %v", tt.HasText("Saved"), tt.Focused("Notify"))
}
if a := tt.Announcements(); !slices.Contains(a, "Saved") {
t.Fatalf("announcements %q", a)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Dismiss notification") {
t.Fatal("Tab does not reach the toast")
}
tt.Key(0, ui.KeyEscape)
if res.Closed != "s" || tt.HasText("Saved") {
t.Fatalf("Escape: %+v", res)
}
}
func TestToastExpiresAndHoverPauses(t *testing.T) {
var res ToasterResult
const life = 40 * time.Millisecond
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Notify", ButtonOptions{}).Clicked() {
ShowToast(c, ToastMessage{Message: "Copied", Duration: life})
}
res = Toaster(c)
}, 600, 400)
_ = tt.Click("Notify")
if res.wake.IsZero() {
t.Fatal("no wake-up scheduled for a running toast")
}
r, _ := tt.Find("Copied")
tt.Move(r.X+r.W/2, r.Y+r.H/2)
if !res.wake.IsZero() {
t.Fatal("hovered toast still schedules its expiry")
}
time.Sleep(2 * life)
released := time.Now()
tt.Move(5, 395)
done := time.Now()
// The frame that ends the hover must schedule the expiry itself.
if res.wake.IsZero() || res.wake.Before(released) || res.wake.After(done.Add(life)) {
t.Fatalf("release scheduled wake %v, released %v", res.wake, released)
}
if !tt.HasText("Copied") {
t.Fatal("hovered toast expired")
}
// Stands in for the frame MyGo's timer runs at res.wake.
time.Sleep(time.Until(res.wake) + 5*time.Millisecond)
tt.Frame()
if tt.HasText("Copied") {
t.Fatal("toast stayed after its wake-up")
}
}
func TestToastSnapshot(t *testing.T) {
snapshot(t, "toast", 480, 300, func(c *ui.Context) {
if len(toasts(c).items) == 0 {
ShowToast(c, ToastMessage{ID: "1", Message: "Settings saved", Severity: SeveritySuccess})
ShowToast(c, ToastMessage{ID: "2", Message: "Note moved to trash", Action: "Undo"})
ShowToast(c, ToastMessage{ID: "3", Message: "Upload failed: network lost", Severity: SeverityError})
ShowToast(c, ToastMessage{ID: "4", Message: "Queued and not shown"})
}
Toaster(c)
})
}