Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc8bf99cd |
@@ -0,0 +1,130 @@
|
||||
// Package household owns household-local calendar and clock logic. Every
|
||||
// function works in the household's IANA zone, passed explicitly, and never
|
||||
// in the process zone (time.Local).
|
||||
//
|
||||
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
|
||||
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
|
||||
//
|
||||
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
|
||||
// plus seven days is always the same weekday a week later.
|
||||
package household
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Today returns the household-local date at instant now in zone loc.
|
||||
func Today(now time.Time, loc *time.Location) store.LocalDate {
|
||||
return store.LocalDateOf(now.In(loc))
|
||||
}
|
||||
|
||||
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
|
||||
// the zero LocalDate.
|
||||
func WeekStart(d store.LocalDate) store.LocalDate {
|
||||
t := midnightUTC(d)
|
||||
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
|
||||
sinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
|
||||
}
|
||||
|
||||
// WeekDays returns the seven dates Monday to Sunday of the week starting on
|
||||
// weekStart. It is an error if weekStart is not a Monday.
|
||||
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
|
||||
t, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return [7]store.LocalDate{}, err
|
||||
}
|
||||
var days [7]store.LocalDate
|
||||
for i := range days {
|
||||
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// WeekLabel returns the display label of the week starting on weekStart, such
|
||||
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
|
||||
// spans two months. It is an error if weekStart is not a Monday.
|
||||
func WeekLabel(weekStart store.LocalDate) (string, error) {
|
||||
start, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, week := start.ISOWeek()
|
||||
end := start.AddDate(0, 0, 6)
|
||||
if end.Month() == start.Month() {
|
||||
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
|
||||
}
|
||||
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
|
||||
}
|
||||
|
||||
// PeriodKey returns the ISO week key of the week containing d, such as
|
||||
// "2026-W41". The year is the ISO week-numbering year, which differs from the
|
||||
// calendar year around New Year. It panics if d is the zero LocalDate.
|
||||
func PeriodKey(d store.LocalDate) string {
|
||||
year, week := midnightUTC(d).ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", year, week)
|
||||
}
|
||||
|
||||
// LocalTime returns the instant at which household clocks in zone loc read
|
||||
// hhmm (24-hour "HH:MM") on date d.
|
||||
//
|
||||
// DST rule: a wall time that does not exist (spring forward) or occurs twice
|
||||
// (fall back) is read with the UTC offset in effect before the transition. A
|
||||
// skipped time therefore moves forward by the length of the gap (02:30 on a
|
||||
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
|
||||
// occurrence. The rule assumes at most one offset change in the 48 hours
|
||||
// around the wall time, which holds for every real zone.
|
||||
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
|
||||
if d.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero date")
|
||||
}
|
||||
clock, err := time.Parse("15:04", hhmm)
|
||||
if err != nil || clock.Format("15:04") != hhmm {
|
||||
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
// wall is the wall-clock reading expressed as if it were UTC; the real
|
||||
// instant is wall minus the zone's UTC offset at that instant.
|
||||
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
|
||||
before := offset(wall.Add(-24*time.Hour), loc)
|
||||
after := offset(wall.Add(24*time.Hour), loc)
|
||||
early := wall.Add(-before)
|
||||
late := wall.Add(-after)
|
||||
// Use the post-transition offset only when the pre-transition reading is
|
||||
// not a real wall time and the post-transition one is.
|
||||
if offset(early, loc) != before && offset(late, loc) == after {
|
||||
return late.In(loc), nil
|
||||
}
|
||||
return early.In(loc), nil
|
||||
}
|
||||
|
||||
// offset returns the UTC offset of zone loc at instant t.
|
||||
func offset(t time.Time, loc *time.Location) time.Duration {
|
||||
_, secs := t.In(loc).Zone()
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
|
||||
func midnightUTC(d store.LocalDate) time.Time {
|
||||
if d.IsZero() {
|
||||
panic("household: zero date")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
|
||||
func monday(weekStart store.LocalDate) (time.Time, error) {
|
||||
if weekStart.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero week start")
|
||||
}
|
||||
t := midnightUTC(weekStart)
|
||||
if t.Weekday() != time.Monday {
|
||||
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) {
|
||||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||||
}
|
||||
|
||||
// LocalDateOf returns the calendar date of t in t's own location.
|
||||
func LocalDateOf(t time.Time) LocalDate {
|
||||
y, m, d := t.Date()
|
||||
return LocalDate{y, m, d}
|
||||
}
|
||||
|
||||
// Date returns the year, month and day of d.
|
||||
func (d LocalDate) Date() (year int, month time.Month, day int) {
|
||||
return d.year, d.month, d.day
|
||||
}
|
||||
|
||||
// String returns the YYYY-MM-DD form.
|
||||
func (d LocalDate) String() string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||||
@@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool {
|
||||
return d == LocalDate{}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error.
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error, as is a
|
||||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||||
func (d LocalDate) Value() (driver.Value, error) {
|
||||
if d.IsZero() {
|
||||
return nil, errors.New("store: zero LocalDate")
|
||||
}
|
||||
if d.year < 0 || d.year > 9999 {
|
||||
return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year)
|
||||
}
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateOf(t *testing.T) {
|
||||
auckland, err := time.LoadLocation("Pacific/Auckland")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13).
|
||||
instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC)
|
||||
if got := LocalDateOf(instant).String(); got != "2026-10-03" {
|
||||
t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got)
|
||||
}
|
||||
d := LocalDateOf(instant.In(auckland))
|
||||
if got := d.String(); got != "2026-10-04" {
|
||||
t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got)
|
||||
}
|
||||
if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 {
|
||||
t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
|
||||
@@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
if _, err := (LocalDate{}).Value(); err == nil {
|
||||
t.Error("zero LocalDate Value succeeded")
|
||||
}
|
||||
for _, y := range []int{-1, 10000} {
|
||||
d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
if _, err := d.Value(); err == nil {
|
||||
t.Errorf("LocalDate year %d Value succeeded", y)
|
||||
}
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(nil); err == nil {
|
||||
t.Error("Scan(nil) succeeded")
|
||||
|
||||
Reference in New Issue
Block a user