CI / ci (push) Successful in 1m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
312 lines
11 KiB
Go
312 lines
11 KiB
Go
package household
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||
)
|
||
|
||
// TestMain runs the package with the process zone set to UTC, so any use of
|
||
// time.Local in place of the household zone shows up as a failure.
|
||
func TestMain(m *testing.M) {
|
||
time.Local = time.UTC
|
||
m.Run()
|
||
}
|
||
|
||
func loadZone(t *testing.T, name string) *time.Location {
|
||
t.Helper()
|
||
loc, err := time.LoadLocation(name)
|
||
if err != nil {
|
||
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||
}
|
||
return loc
|
||
}
|
||
|
||
func date(t *testing.T, s string) store.LocalDate {
|
||
t.Helper()
|
||
d, err := store.ParseLocalDate(s)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return d
|
||
}
|
||
|
||
func utc(t *testing.T, s string) time.Time {
|
||
t.Helper()
|
||
ts, err := time.Parse(time.RFC3339, s)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return ts
|
||
}
|
||
|
||
func TestTodayUsesHouseholdZone(t *testing.T) {
|
||
chicago := loadZone(t, "America/Chicago")
|
||
auckland := loadZone(t, "Pacific/Auckland")
|
||
for _, tc := range []struct {
|
||
now string
|
||
loc *time.Location
|
||
want string
|
||
}{
|
||
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
|
||
// Monday 17:30 in Auckland (NZDT, UTC+13).
|
||
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
|
||
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
|
||
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
|
||
// 00:30 in Auckland.
|
||
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
|
||
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
|
||
} {
|
||
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
|
||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
zone, now string
|
||
wantDay string
|
||
wantWeekStart string
|
||
wantPeriodKey string
|
||
wantWeekday time.Weekday
|
||
}{
|
||
// Sunday 23:59 local belongs to the week that started the previous
|
||
// Monday; one minute later, Monday 00:00 starts a new week.
|
||
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||
} {
|
||
loc := loadZone(t, tc.zone)
|
||
now := utc(t, tc.now)
|
||
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
|
||
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
|
||
}
|
||
today := Today(now, loc)
|
||
if today.String() != tc.wantDay {
|
||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
|
||
}
|
||
if got := WeekStart(today).String(); got != tc.wantWeekStart {
|
||
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
|
||
}
|
||
if got := PeriodKey(today); got != tc.wantPeriodKey {
|
||
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestWeekStartEveryWeekday(t *testing.T) {
|
||
for _, d := range []string{
|
||
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
|
||
"2026-10-09", "2026-10-10", "2026-10-11",
|
||
} {
|
||
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
|
||
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
|
||
}
|
||
}
|
||
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
|
||
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
|
||
}
|
||
}
|
||
|
||
func TestWeekDays(t *testing.T) {
|
||
days, err := WeekDays(date(t, "2026-09-28"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := []string{
|
||
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
|
||
"2026-10-02", "2026-10-03", "2026-10-04",
|
||
}
|
||
for i, d := range days {
|
||
if d.String() != want[i] {
|
||
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestWeekDaysAcrossDSTChange(t *testing.T) {
|
||
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
|
||
// Sunday 2026-09-27; neither shifts the dates of those weeks.
|
||
for _, tc := range []struct{ start, last string }{
|
||
{"2026-10-26", "2026-11-01"},
|
||
{"2026-09-21", "2026-09-27"},
|
||
{"2026-03-02", "2026-03-08"},
|
||
} {
|
||
days, err := WeekDays(date(t, tc.start))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if days[0].String() != tc.start || days[6].String() != tc.last {
|
||
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestISOWeekYearBoundary(t *testing.T) {
|
||
for _, tc := range []struct{ day, weekStart, key string }{
|
||
{"2026-12-27", "2026-12-21", "2026-W52"},
|
||
{"2026-12-28", "2026-12-28", "2026-W53"},
|
||
{"2026-12-31", "2026-12-28", "2026-W53"},
|
||
{"2027-01-01", "2026-12-28", "2026-W53"},
|
||
{"2027-01-03", "2026-12-28", "2026-W53"},
|
||
{"2027-01-04", "2027-01-04", "2027-W01"},
|
||
{"2027-01-10", "2027-01-04", "2027-W01"},
|
||
{"2026-01-01", "2025-12-29", "2026-W01"},
|
||
{"2026-10-07", "2026-10-05", "2026-W41"},
|
||
} {
|
||
d := date(t, tc.day)
|
||
if got := WeekStart(d).String(); got != tc.weekStart {
|
||
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
|
||
}
|
||
if got := PeriodKey(d); got != tc.key {
|
||
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
|
||
}
|
||
}
|
||
days, err := WeekDays(date(t, "2026-12-28"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if days[6].String() != "2027-01-03" {
|
||
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
|
||
}
|
||
}
|
||
|
||
func TestWeekLabel(t *testing.T) {
|
||
for _, tc := range []struct{ start, want string }{
|
||
{"2026-10-05", "Week 41 · Oct 5 – 11"},
|
||
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
|
||
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
|
||
{"2027-01-04", "Week 1 · Jan 4 – 10"},
|
||
} {
|
||
got, err := WeekLabel(date(t, tc.start))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got != tc.want {
|
||
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestWeekStartRequired(t *testing.T) {
|
||
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
|
||
if _, err := WeekDays(d); err == nil {
|
||
t.Errorf("WeekDays(%s) succeeded, want error", d)
|
||
}
|
||
if _, err := WeekLabel(d); err == nil {
|
||
t.Errorf("WeekLabel(%s) succeeded, want error", d)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestZeroDatePanics(t *testing.T) {
|
||
for name, fn := range map[string]func(){
|
||
"WeekStart": func() { WeekStart(store.LocalDate{}) },
|
||
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
|
||
} {
|
||
func() {
|
||
defer func() {
|
||
if recover() == nil {
|
||
t.Errorf("%s(zero) did not panic", name)
|
||
}
|
||
}()
|
||
fn()
|
||
}()
|
||
}
|
||
}
|
||
|
||
func TestLocalTime(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
zone, day, hhmm string
|
||
want string // UTC instant
|
||
wantWall string // local wall clock of the result
|
||
}{
|
||
// Ordinary days.
|
||
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
|
||
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
|
||
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
|
||
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
|
||
|
||
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
|
||
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
|
||
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||
|
||
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
|
||
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
|
||
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
|
||
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
|
||
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
|
||
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
|
||
|
||
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
|
||
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
|
||
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
|
||
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
|
||
|
||
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
|
||
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
|
||
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
|
||
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
|
||
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
|
||
} {
|
||
loc := loadZone(t, tc.zone)
|
||
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
|
||
if err != nil {
|
||
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
|
||
}
|
||
if !got.Equal(utc(t, tc.want)) {
|
||
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
|
||
}
|
||
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
|
||
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
|
||
}
|
||
if got.Location() != loc {
|
||
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLocalTimeDeterministic(t *testing.T) {
|
||
// The same ambiguous or skipped input always resolves to the same instant.
|
||
chicago := loadZone(t, "America/Chicago")
|
||
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
|
||
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for range 100 {
|
||
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !again.Equal(first) {
|
||
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLocalTimeRejectsInvalid(t *testing.T) {
|
||
chicago := loadZone(t, "America/Chicago")
|
||
d := date(t, "2026-10-05")
|
||
for _, hhmm := range []string{
|
||
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
|
||
} {
|
||
if got, err := LocalTime(chicago, d, hhmm); err == nil {
|
||
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
|
||
} else if !strings.Contains(err.Error(), "HH:MM") {
|
||
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
|
||
}
|
||
}
|
||
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
|
||
t.Error("LocalTime(zero date) succeeded")
|
||
}
|
||
}
|