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
9.6 KiB
Go
288 lines
9.6 KiB
Go
package datetime_test
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
func TestSunDialDragsHoursAndShowsTheSky(t *testing.T) {
|
|
hour, changes := 9, 0
|
|
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
|
|
if datetime.SunDial(c, "Hour", &hour, datetime.SunDialOptions{}).Changed {
|
|
changes++
|
|
}
|
|
})
|
|
r := find(t, tt, "Hour")
|
|
tt.Press(r.X+r.W/2+2, r.Y+20)
|
|
tt.Release(r.X+r.W/2+2, r.Y+20)
|
|
if hour != 12 {
|
|
t.Fatalf("a press at the middle set %d", hour)
|
|
}
|
|
// Noon puts the sun at the top of the arc.
|
|
if n := count(t, tt.Image(), ui.Rect{X: r.X + r.W/2 - 12, Y: r.Y + 2*(56-6-40) - 12, W: 24, H: 24}, pixel.Warn); n == 0 {
|
|
t.Fatal("no sun at the top at noon")
|
|
}
|
|
tt.Key(0, ui.KeyLeft)
|
|
tt.Key(0, ui.KeyLeft)
|
|
if hour != 10 || changes != 3 {
|
|
t.Fatalf("Left, Left gave %d, %d changes", hour, changes)
|
|
}
|
|
hour = 23
|
|
tt.Frame()
|
|
// At 23:00 the moon sits left of the top.
|
|
moon := ui.Rect{X: r.X + 76 - 12, Y: r.Y + 22 - 12, W: 24, H: 24}
|
|
if count(t, tt.Image(), moon, pixel.Warn) == 0 || count(t, tt.Image(), r, pixel.Shade) < 100 {
|
|
t.Fatal("no moon at eleven at night")
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestSeasonWheelPicksByClickAndKeys(t *testing.T) {
|
|
season := pixel.Spring
|
|
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
|
|
datetime.SeasonWheel(c, "Season", &season, datetime.SeasonWheelOptions{})
|
|
})
|
|
r := find(t, tt, "Season")
|
|
tt.ClickAt(r.X+20, r.Y+r.H-20)
|
|
if season != pixel.Autumn {
|
|
t.Fatalf("a click at the bottom left chose %v", season)
|
|
}
|
|
tt.Key(0, ui.KeyRight)
|
|
tt.Key(0, ui.KeyRight)
|
|
if season != pixel.Spring {
|
|
t.Fatalf("Right twice from autumn chose %v", season)
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestWeekStripStepsAcrossWeeks(t *testing.T) {
|
|
date := time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC)
|
|
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
|
|
datetime.WeekStrip(c, "Week", &date, datetime.WeekStripOptions{})
|
|
})
|
|
r := find(t, tt, "Week")
|
|
tt.ClickAt(r.X+4, r.Y+4)
|
|
if day(date) != "2026-10-11" {
|
|
t.Fatalf("a click on Sunday gave %s", day(date))
|
|
}
|
|
tt.Key(0, ui.KeyLeft)
|
|
if day(date) != "2026-10-10" {
|
|
t.Fatalf("Left from Sunday gave %s", day(date))
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
var raid = time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC)
|
|
|
|
func TestAgendaGroupsByDay(t *testing.T) {
|
|
events := []datetime.Event{
|
|
{Start: raid.Add(26 * time.Hour), Title: "Shop"},
|
|
{Start: raid, Title: "Raid"},
|
|
{Start: raid.Add(3 * time.Hour), Title: "Lunch"},
|
|
}
|
|
tt := view(300, 300, newClock(), false, func(c *ui.Context) {
|
|
datetime.Agenda(c, events, datetime.AgendaOptions{})
|
|
})
|
|
a, b, s := find(t, tt, "SU 11 OCT"), find(t, tt, "09:00 Raid"), find(t, tt, "MO 12 OCT")
|
|
l, sh := find(t, tt, "12:00 Lunch"), find(t, tt, "11:00 Shop")
|
|
if !(a.Y < b.Y && b.Y < l.Y && l.Y < s.Y && s.Y < sh.Y) {
|
|
t.Fatalf("order: %q", tt.Texts())
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestAlarmLeverAndBadTimes(t *testing.T) {
|
|
alarms := []datetime.AlarmEntry{{Hour: 7, Minute: 30, Days: [7]bool{1: true, 2: true}}}
|
|
changes := 0
|
|
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
|
|
if datetime.Alarm(c, "Alarms", alarms, datetime.AlarmOptions{}).Changed {
|
|
changes++
|
|
}
|
|
})
|
|
tt.Key(0, ui.KeyTab)
|
|
tt.Key(0, ui.KeySpace)
|
|
if !alarms[0].On || changes != 1 {
|
|
t.Fatalf("the lever left On %v", alarms[0].On)
|
|
}
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("25:00 did not panic")
|
|
}
|
|
}()
|
|
view(100, 100, newClock(), false, func(c *ui.Context) {
|
|
datetime.Alarm(c, "x", []datetime.AlarmEntry{{Hour: 25}}, datetime.AlarmOptions{})
|
|
})
|
|
}
|
|
|
|
func TestYearViewMarksToday(t *testing.T) {
|
|
tt := view(600, 400, newClock(), false, func(c *ui.Context) {
|
|
datetime.YearView(c, 2026, datetime.YearViewOptions{})
|
|
})
|
|
r := find(t, tt, "2026")
|
|
if n := count(t, tt.Image(), r, pixel.Accent); n == 0 {
|
|
t.Fatal("today is not marked")
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestDaySchedulePlacesAndSplitsOverlaps(t *testing.T) {
|
|
events := []datetime.Event{
|
|
{Start: raid, Length: 2 * time.Hour, Title: "Raid"},
|
|
{Start: raid.Add(time.Hour), Length: time.Hour, Title: "Call"},
|
|
{Start: raid.Add(4 * time.Hour), Length: 30 * time.Minute, Title: "Lunch"},
|
|
}
|
|
tt := view(400, 500, newClock(), false, func(c *ui.Context) {
|
|
datetime.DaySchedule(c, events, datetime.DayScheduleOptions{})
|
|
})
|
|
tt.Frame()
|
|
a, b, l := find(t, tt, "09:00 Raid"), find(t, tt, "10:00 Call"), find(t, tt, "13:00 Lunch")
|
|
// Hours are 40 pixels; nine o'clock is the second row.
|
|
if a.Y != 40 || b.Y != 80 || b.X <= a.X || l.X != a.X || a.H != 80 || l.W < 2*a.W {
|
|
t.Fatalf("raid %v, call %v, lunch %v", a, b, l)
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestEventChipReadsTimeAndTitle(t *testing.T) {
|
|
tt := view(300, 60, newClock(), false, func(c *ui.Context) {
|
|
datetime.EventChip(c, datetime.Event{Start: raid, Title: "Raid"}, datetime.EventChipOptions{})
|
|
})
|
|
if !shows(tt, "09:00 Raid") {
|
|
t.Fatalf("texts %q", tt.Texts())
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestOffDateTimeControlsStayPut(t *testing.T) {
|
|
for _, inert := range []bool{false, true} {
|
|
off := !inert
|
|
tm := raid
|
|
week := raid
|
|
month, year, hour, season := raid, 2026, 9, pixel.Spring
|
|
alarms := []datetime.AlarmEntry{{Hour: 7}}
|
|
started := 0
|
|
tt := view(800, 600, newClock(), inert, func(c *ui.Context) {
|
|
ui.Row(c).Gap(8).Children(func() {
|
|
ui.Column(c).Gap(8).Children(func() {
|
|
datetime.TimeInput(c, "Time", &tm, datetime.TimeInputOptions{Disabled: off})
|
|
datetime.WeekStrip(c, "Week", &week, datetime.WeekStripOptions{Disabled: off})
|
|
datetime.MonthPicker(c, "Month", &month, datetime.MonthPickerOptions{Disabled: off})
|
|
datetime.YearPicker(c, "Year", &year, datetime.YearPickerOptions{Disabled: off})
|
|
})
|
|
ui.Column(c).Gap(8).Children(func() {
|
|
datetime.SunDial(c, "Hour", &hour, datetime.SunDialOptions{Disabled: off})
|
|
datetime.SeasonWheel(c, "Season", &season, datetime.SeasonWheelOptions{Disabled: off})
|
|
datetime.Alarm(c, "Alarms", alarms, datetime.AlarmOptions{Disabled: off})
|
|
if datetime.Stopwatch(c, "Run", datetime.StopwatchOptions{Disabled: off}).Started {
|
|
started++
|
|
}
|
|
})
|
|
})
|
|
})
|
|
for _, l := range []string{"Time", "Week", "Month", "Year", "Season", "START"} {
|
|
r := find(t, tt, l)
|
|
tt.ClickAt(r.X+r.W-4, r.Y+r.H-4)
|
|
tt.Key(0, ui.KeyRight)
|
|
tt.Key(0, ui.KeyUp)
|
|
tt.Key(0, ui.KeyEnter)
|
|
}
|
|
h := find(t, tt, "Hour")
|
|
tt.Press(h.X+4, h.Y+4)
|
|
tt.Release(h.X+4, h.Y+4)
|
|
tt.Key(0, ui.KeyTab)
|
|
tt.Key(0, ui.KeySpace)
|
|
if !tm.Equal(raid) || !week.Equal(raid) || !month.Equal(raid) || year != 2026 || hour != 9 || season != pixel.Spring || alarms[0].On || started != 0 {
|
|
t.Fatalf("inert %v: off controls changed", inert)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDayScheduleKeepsLateBlocksInside(t *testing.T) {
|
|
late := time.Date(2026, 10, 11, 17, 55, 0, 0, time.UTC)
|
|
tt := view(400, 500, newClock(), false, func(c *ui.Context) {
|
|
datetime.DaySchedule(c, []datetime.Event{{Start: late, Length: 5 * time.Minute, Title: "Bell"}}, datetime.DayScheduleOptions{})
|
|
})
|
|
tt.Frame()
|
|
// Five minutes take the three-dot least: six pixels.
|
|
if b := find(t, tt, "17:55 Bell"); b.Y+b.H > 400 || b.H != 6 {
|
|
t.Fatalf("a late five-minute block is %v", b)
|
|
}
|
|
}
|
|
|
|
func TestMonthPickerFitsChineseNames(t *testing.T) {
|
|
month := raid
|
|
tt := ui.NewTester(func(c *ui.Context) {
|
|
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: newClock().now, Locale: pixel.Zh})
|
|
datetime.MonthPicker(c, "Month", &month, datetime.MonthPickerOptions{})
|
|
}, 600, 300)
|
|
if w := find(t, tt, "Month").W; w < 4*float32(pixel.Measure("十二月")+2)*2 {
|
|
t.Fatalf("cells of %v pixels for four 十二月", w/4)
|
|
}
|
|
keeps(t, tt.Image())
|
|
}
|
|
|
|
func TestTodayMarksMoveAtMidnight(t *testing.T) {
|
|
clk := newClock()
|
|
clk.t = time.Date(2026, 10, 11, 23, 59, 59, 0, time.UTC)
|
|
date := clk.t
|
|
for name, build := range map[string]func(c *ui.Context){
|
|
"calendar": func(c *ui.Context) { datetime.Calendar(c, "Date", &date, datetime.CalendarOptions{}) },
|
|
"week": func(c *ui.Context) { datetime.WeekStrip(c, "Week", &date, datetime.WeekStripOptions{}) },
|
|
"year": func(c *ui.Context) { datetime.YearView(c, 2026, datetime.YearViewOptions{}) },
|
|
} {
|
|
tt := view(600, 400, clk, false, build)
|
|
pixel.ResetDue()
|
|
before := tt.Now()
|
|
tt.Frame()
|
|
if due := pixel.Due(); due.IsZero() || due.Sub(before) > 2*time.Second {
|
|
t.Fatalf("%s: no rebuild asked for midnight, due %v", name, due)
|
|
}
|
|
marks := accents(t, tt)
|
|
clk.t = clk.t.Add(2 * time.Second)
|
|
tt.Frame()
|
|
after := accents(t, tt)
|
|
clk.t = clk.t.Add(-2 * time.Second)
|
|
if len(marks) == 0 || len(after) == 0 || slices.Equal(marks, after) {
|
|
t.Fatalf("%s: the today mark did not move at midnight", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRelativeTimeRoundsTheFutureUp(t *testing.T) {
|
|
clk := newClock()
|
|
for _, tc := range []struct {
|
|
ahead time.Duration
|
|
words string
|
|
wait time.Duration
|
|
}{{30 * time.Second, "IN 1 MIN", 30 * time.Second}, {90 * time.Second, "IN 2 MIN", 30 * time.Second}, {2 * time.Minute, "IN 2 MIN", time.Minute}} {
|
|
at := clk.t.Add(tc.ahead)
|
|
tt := view(300, 60, clk, false, func(c *ui.Context) { datetime.RelativeTime(c, at, datetime.RelativeTimeOptions{}) })
|
|
pixel.ResetDue()
|
|
before := tt.Now()
|
|
tt.Frame()
|
|
if wait := pixel.Due().Sub(before); !shows(tt, tc.words) || wait < tc.wait-time.Second || wait > tc.wait+time.Second {
|
|
t.Fatalf("%v ahead: %q, next repaint in %v", tc.ahead, tt.Texts(), wait)
|
|
}
|
|
}
|
|
}
|
|
|
|
// accents lists where Accent dots are.
|
|
func accents(t *testing.T, tt *ui.Tester) []int {
|
|
t.Helper()
|
|
img, out := tt.Image(), []int{}
|
|
for y := 0; y < img.Bounds().Dy(); y += 2 {
|
|
for x := 0; x < img.Bounds().Dx(); x += 2 {
|
|
if slot(t, img, x, y) == pixel.Accent {
|
|
out = append(out, y*img.Bounds().Dx()+x)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|