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.
209 lines
6.3 KiB
Go
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)
|
|
})
|
|
}
|