Phase 3: Implement date format parsers and duration extensions
- Add month name parsing (jan, january, feb, etc.) with year logic - Add day+month parsing (21jan, Jan21, 21January, etc.) in all case variations - Add period boundaries (sod, eod, sow, eow, som, eom, soy, eoy) - Add special keywords (later, someday -> 2150-01-01) - Add duration-as-date offset (2d, 3w, etc. means X from now) - Add named duration aliases (daily, weekly, monthly, yearly) - Enhance ParseRecurrencePattern to support min, sec, hrs and word forms - Implement time-of-day parsing (mon:15:35, tomorrow:0800, 15:35) - Add comprehensive test suite (50+ tests total) - All tests passing
This commit is contained in:
@@ -48,6 +48,11 @@ func (p *DateParser) ParseDate(s string) (time.Time, error) {
|
||||
|
||||
// parseDateOnly parses just the date component without time
|
||||
func (p *DateParser) parseDateOnly(s string) (time.Time, error) {
|
||||
// Empty string means "today" (used when parsing just time like "15:35")
|
||||
if s == "" {
|
||||
return time.Date(p.base.Year(), p.base.Month(), p.base.Day(), 0, 0, 0, 0, p.base.Location()), nil
|
||||
}
|
||||
|
||||
// Try ISO format first
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
return t, nil
|
||||
@@ -102,27 +107,47 @@ func (p *DateParser) splitDateTime(s string) (string, string, bool) {
|
||||
return s, "", false
|
||||
}
|
||||
|
||||
// Check last part for time pattern (2-4 digits)
|
||||
lastPart := parts[len(parts)-1]
|
||||
if len(lastPart) == 2 || len(lastPart) == 4 {
|
||||
// Could be minutes (HH:MM) or HHMM format
|
||||
if _, err := strconv.Atoi(lastPart); err == nil {
|
||||
// Last part is numeric, could be time
|
||||
if len(parts) == 2 {
|
||||
// Format: "15:35" (just time, no date)
|
||||
return "", s, true
|
||||
} else if len(parts) >= 3 {
|
||||
// Format: "mon:15:35" or similar
|
||||
secondLast := parts[len(parts)-2]
|
||||
if _, err := strconv.Atoi(secondLast); err == nil {
|
||||
// HH:MM format
|
||||
dateStr := strings.Join(parts[:len(parts)-2], ":")
|
||||
timeStr := secondLast + ":" + lastPart
|
||||
return dateStr, timeStr, true
|
||||
} else if len(lastPart) == 4 {
|
||||
// HHMM format
|
||||
dateStr := strings.Join(parts[:len(parts)-1], ":")
|
||||
return dateStr, lastPart, true
|
||||
|
||||
// Format: "mon:15:35" or "tomorrow:15:35" (HH:MM with 3+ parts)
|
||||
if len(parts) >= 3 {
|
||||
// Check if last two parts form a valid time
|
||||
secondLast := parts[len(parts)-2]
|
||||
if h, err := strconv.Atoi(secondLast); err == nil && h >= 0 && h <= 23 {
|
||||
if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 {
|
||||
// HH:MM format
|
||||
dateStr := strings.Join(parts[:len(parts)-2], ":")
|
||||
if dateStr == "" {
|
||||
dateStr = "today" // Just time means today
|
||||
}
|
||||
timeStr := secondLast + ":" + lastPart
|
||||
return dateStr, timeStr, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format with 2 parts: could be "15:35" (just time) or "mon:0800" (date+HHMM)
|
||||
if len(parts) == 2 {
|
||||
// Check if lastPart is HHMM format (4 digits)
|
||||
if len(lastPart) == 4 {
|
||||
if _, err := strconv.Atoi(lastPart); err == nil {
|
||||
h, _ := strconv.Atoi(lastPart[0:2])
|
||||
m, _ := strconv.Atoi(lastPart[2:4])
|
||||
if h >= 0 && h <= 23 && m >= 0 && m <= 59 {
|
||||
// Valid HHMM time
|
||||
// If first part is also numeric, it's a date part (like "21jan:0800" but already lowercase)
|
||||
// Otherwise it's a keyword like "tomorrow:0800"
|
||||
return parts[0], lastPart, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's "HH:MM" format (both parts are 2-digit numbers)
|
||||
if len(parts[0]) <= 2 && len(lastPart) <= 2 {
|
||||
if h, err := strconv.Atoi(parts[0]); err == nil && h >= 0 && h <= 23 {
|
||||
if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 {
|
||||
// Just time, no date
|
||||
return "", parts[0] + ":" + lastPart, true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,32 +238,223 @@ func (p *DateParser) parseWeekday(s string) (time.Time, bool) {
|
||||
|
||||
// parseMonthName handles month names (jan, january, feb, february, etc.)
|
||||
func (p *DateParser) parseMonthName(s string) (time.Time, bool) {
|
||||
// Placeholder for Phase 2
|
||||
months := map[string]time.Month{
|
||||
"jan": time.January, "january": time.January,
|
||||
"feb": time.February, "february": time.February,
|
||||
"mar": time.March, "march": time.March,
|
||||
"apr": time.April, "april": time.April,
|
||||
"may": time.May,
|
||||
"jun": time.June, "june": time.June,
|
||||
"jul": time.July, "july": time.July,
|
||||
"aug": time.August, "august": time.August,
|
||||
"sep": time.September, "september": time.September,
|
||||
"oct": time.October, "october": time.October,
|
||||
"nov": time.November, "november": time.November,
|
||||
"dec": time.December, "december": time.December,
|
||||
}
|
||||
|
||||
month, ok := months[s]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// Set to first of month
|
||||
// If that date has passed this year, use next year
|
||||
year := p.base.Year()
|
||||
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location())
|
||||
|
||||
if firstOfMonth.Before(p.base) || firstOfMonth.Equal(p.base) {
|
||||
// First of month has passed, use next year
|
||||
year++
|
||||
firstOfMonth = time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location())
|
||||
}
|
||||
|
||||
return firstOfMonth, true
|
||||
}
|
||||
|
||||
// parseDayMonth handles day+month formats (21jan, Jan21, 21January, January21)
|
||||
func (p *DateParser) parseDayMonth(s string) (time.Time, bool) {
|
||||
// Try pattern: digits followed by month name (21jan, 21January)
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= '0' && s[i] <= '9' {
|
||||
continue
|
||||
}
|
||||
// Found first non-digit
|
||||
if i > 0 && i < len(s) {
|
||||
dayStr := s[:i]
|
||||
monthStr := strings.ToLower(s[i:])
|
||||
if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok {
|
||||
return p.constructDate(day, month), true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Try pattern: month name followed by digits (jan21, January21)
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 'a' && s[i] <= 'z' || s[i] >= 'A' && s[i] <= 'Z' {
|
||||
continue
|
||||
}
|
||||
// Found first non-letter
|
||||
if i > 0 && i < len(s) {
|
||||
monthStr := strings.ToLower(s[:i])
|
||||
dayStr := s[i:]
|
||||
if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok {
|
||||
return p.constructDate(day, month), true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// parseDayMonth handles day+month formats (21jan, Jan21, etc.)
|
||||
func (p *DateParser) parseDayMonth(s string) (time.Time, bool) {
|
||||
// Placeholder for Phase 2
|
||||
return time.Time{}, false
|
||||
// parseDayAndMonth extracts day and month from string parts
|
||||
func (p *DateParser) parseDayAndMonth(dayStr, monthStr string) (int, time.Month, bool) {
|
||||
day, err := strconv.Atoi(dayStr)
|
||||
if err != nil || day < 1 || day > 31 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
months := map[string]time.Month{
|
||||
"jan": time.January, "january": time.January,
|
||||
"feb": time.February, "february": time.February,
|
||||
"mar": time.March, "march": time.March,
|
||||
"apr": time.April, "april": time.April,
|
||||
"may": time.May,
|
||||
"jun": time.June, "june": time.June,
|
||||
"jul": time.July, "july": time.July,
|
||||
"aug": time.August, "august": time.August,
|
||||
"sep": time.September, "september": time.September,
|
||||
"oct": time.October, "october": time.October,
|
||||
"nov": time.November, "november": time.November,
|
||||
"dec": time.December, "december": time.December,
|
||||
}
|
||||
|
||||
month, ok := months[monthStr]
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return day, month, true
|
||||
}
|
||||
|
||||
// constructDate creates a date with year logic (current year if future, next year if past)
|
||||
func (p *DateParser) constructDate(day int, month time.Month) time.Time {
|
||||
year := p.base.Year()
|
||||
date := time.Date(year, month, day, 0, 0, 0, 0, p.base.Location())
|
||||
|
||||
// Validate day is valid for month (handles Feb 30, etc.)
|
||||
if date.Month() != month {
|
||||
// Invalid day for month, return zero time (will be caught as parse error)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
if date.Before(p.base) || date.Equal(p.base) {
|
||||
// Date has passed, use next year
|
||||
year++
|
||||
date = time.Date(year, month, day, 0, 0, 0, 0, p.base.Location())
|
||||
}
|
||||
|
||||
return date
|
||||
}
|
||||
|
||||
// parsePeriodBoundary handles period boundary keywords (sod, eod, sow, eow, etc.)
|
||||
func (p *DateParser) parsePeriodBoundary(s string) (time.Time, bool) {
|
||||
// Placeholder for Phase 2
|
||||
return time.Time{}, false
|
||||
now := p.base
|
||||
loc := now.Location()
|
||||
|
||||
switch s {
|
||||
case "sod":
|
||||
// Start of day - today at 00:00:00
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc), true
|
||||
|
||||
case "eod":
|
||||
// End of day - today at 23:59:59
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, loc), true
|
||||
|
||||
case "sow":
|
||||
// Start of week - next occurrence of week start day at 00:00:00
|
||||
return p.nextWeekday(p.weekStart), true
|
||||
|
||||
case "eow":
|
||||
// End of week - next occurrence of week end day at 23:59:59
|
||||
weekEnd := p.weekStart - 1
|
||||
if weekEnd < 0 {
|
||||
weekEnd = 6
|
||||
}
|
||||
endDate := p.nextWeekday(weekEnd)
|
||||
return time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 0, loc), true
|
||||
|
||||
case "som":
|
||||
// Start of month - 1st of current month at 00:00:00
|
||||
// If we're past the 1st, use next month
|
||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||
if firstOfMonth.Before(now) || firstOfMonth.Equal(now) {
|
||||
// Already past the 1st, use next month
|
||||
firstOfMonth = time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
return firstOfMonth, true
|
||||
|
||||
case "eom":
|
||||
// End of month - last day of current month at 23:59:59
|
||||
// Get first day of next month, then subtract one day
|
||||
firstOfNextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc)
|
||||
lastOfMonth := firstOfNextMonth.AddDate(0, 0, -1)
|
||||
return time.Date(lastOfMonth.Year(), lastOfMonth.Month(), lastOfMonth.Day(), 23, 59, 59, 0, loc), true
|
||||
|
||||
case "soy":
|
||||
// Start of year - Jan 1 of current year at 00:00:00
|
||||
// If we're past Jan 1, use next year
|
||||
firstOfYear := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
|
||||
if firstOfYear.Before(now) || firstOfYear.Equal(now) {
|
||||
firstOfYear = time.Date(now.Year()+1, time.January, 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
return firstOfYear, true
|
||||
|
||||
case "eoy":
|
||||
// End of year - Dec 31 of current year at 23:59:59
|
||||
return time.Date(now.Year(), time.December, 31, 23, 59, 59, 0, loc), true
|
||||
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpecialKeyword handles special keywords (later, someday)
|
||||
func (p *DateParser) parseSpecialKeyword(s string) (time.Time, bool) {
|
||||
// Placeholder for Phase 2
|
||||
return time.Time{}, false
|
||||
switch s {
|
||||
case "later", "someday":
|
||||
// Far future: 2150-01-01 at 00:00:00
|
||||
return time.Date(2150, time.January, 1, 0, 0, 0, 0, p.base.Location()), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseDurationAsDate handles duration as date offset (2d, 3w, etc.)
|
||||
// parseDurationAsDate handles duration as date offset (2d, 3w, etc.) and named durations
|
||||
func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) {
|
||||
// Placeholder for Phase 2
|
||||
return time.Time{}, false
|
||||
// Named duration aliases
|
||||
namedDurations := map[string]string{
|
||||
"daily": "1d",
|
||||
"weekly": "1w",
|
||||
"monthly": "30d",
|
||||
"yearly": "1y",
|
||||
}
|
||||
|
||||
// Check for named duration
|
||||
if pattern, ok := namedDurations[s]; ok {
|
||||
s = pattern
|
||||
}
|
||||
|
||||
// Try parsing as duration
|
||||
duration, err := ParseRecurrencePattern(s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// Add duration to base time
|
||||
return p.base.Add(duration), true
|
||||
}
|
||||
|
||||
// ParseDuration parses duration strings (1d, 2w, 5min, etc.)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseMonthName(t *testing.T) {
|
||||
// Base: Jan 5, 2026
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{"jan (passed)", "jan", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"january (passed)", "january", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"feb (future)", "feb", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"february (future)", "february", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"mar", "mar", time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"march", "march", time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"dec", "dec", time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"december", "december", time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, ok := parser.parseMonthName(tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("Failed to parse month name: %s", tt.input)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDayMonth(t *testing.T) {
|
||||
// Base: Jan 5, 2026
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{"21jan (future this year)", "21jan", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)},
|
||||
{"21Jan", "21Jan", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)},
|
||||
{"Jan21", "Jan21", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)},
|
||||
{"jan21", "jan21", time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC)},
|
||||
{"1jan (passed)", "1jan", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"30dec", "30dec", time.Date(2026, 12, 30, 0, 0, 0, 0, time.UTC)},
|
||||
{"15feb", "15feb", time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)},
|
||||
{"15February", "15February", time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, ok := parser.parseDayMonth(tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("Failed to parse day+month: %s", tt.input)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePeriodBoundary(t *testing.T) {
|
||||
// Base: Monday, Jan 5, 2026, 14:30:00
|
||||
base := time.Date(2026, 1, 5, 14, 30, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{"sod", "sod", time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)},
|
||||
{"eod", "eod", time.Date(2026, 1, 5, 23, 59, 59, 0, time.UTC)},
|
||||
{"sow", "sow", time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)}, // Next Monday
|
||||
{"eow", "eow", time.Date(2026, 1, 11, 23, 59, 59, 0, time.UTC)}, // Next Sunday
|
||||
{"som", "som", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)}, // Next month (Jan 1 passed)
|
||||
{"eom", "eom", time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)}, // End of Jan
|
||||
{"soy", "soy", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)}, // Next year (Jan 1 passed)
|
||||
{"eoy", "eoy", time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC)}, // End of 2026
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, ok := parser.parsePeriodBoundary(tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("Failed to parse period boundary: %s", tt.input)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpecialKeyword(t *testing.T) {
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{"later", "later", time.Date(2150, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"someday", "someday", time.Date(2150, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, ok := parser.parseSpecialKeyword(tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("Failed to parse special keyword: %s", tt.input)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationAsDate(t *testing.T) {
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{"2d", "2d", base.AddDate(0, 0, 2)},
|
||||
{"3w", "3w", base.AddDate(0, 0, 21)},
|
||||
{"1m", "1m", base.AddDate(0, 0, 30)},
|
||||
{"1y", "1y", base.AddDate(0, 0, 365)},
|
||||
{"daily", "daily", base.AddDate(0, 0, 1)},
|
||||
{"weekly", "weekly", base.AddDate(0, 0, 7)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, ok := parser.parseDurationAsDate(tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("Failed to parse duration as date: %s", tt.input)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeOfDay(t *testing.T) {
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date time.Time
|
||||
timeStr string
|
||||
expected time.Time
|
||||
wantErr bool
|
||||
}{
|
||||
{"HH:MM format", base, "15:35", time.Date(2026, 1, 5, 15, 35, 0, 0, time.UTC), false},
|
||||
{"HHMM format", base, "0800", time.Date(2026, 1, 5, 8, 0, 0, 0, time.UTC), false},
|
||||
{"midnight", base, "00:00", time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), false},
|
||||
{"end of day", base, "23:59", time.Date(2026, 1, 5, 23, 59, 0, 0, time.UTC), false},
|
||||
{"invalid hour", base, "25:00", time.Time{}, true},
|
||||
{"invalid minute", base, "12:65", time.Time{}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parser.parseTimeOfDay(tt.date, tt.timeStr)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateWithTime(t *testing.T) {
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC) // Monday
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{"mon:15:35", "mon:15:35", time.Date(2026, 1, 12, 15, 35, 0, 0, time.UTC)}, // Next Monday at 15:35
|
||||
{"tomorrow:0800", "tomorrow:0800", time.Date(2026, 1, 6, 8, 0, 0, 0, time.UTC)},
|
||||
{"21jan:1430", "21jan:1430", time.Date(2026, 1, 21, 14, 30, 0, 0, time.UTC)},
|
||||
{"just time 15:35", "15:35", time.Date(2026, 1, 5, 15, 35, 0, 0, time.UTC)}, // Today at 15:35
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parser.ParseDate(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse date with time: %v", err)
|
||||
}
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandedDurationFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"5sec", "5sec", 5 * time.Second},
|
||||
{"5seconds", "5seconds", 5 * time.Second},
|
||||
{"10min", "10min", 10 * time.Minute},
|
||||
{"10minutes", "10minutes", 10 * time.Minute},
|
||||
{"2hrs", "2hrs", 2 * time.Hour},
|
||||
{"2hours", "2hours", 2 * time.Hour},
|
||||
{"3days", "3days", 3 * 24 * time.Hour},
|
||||
{"2weeks", "2weeks", 2 * 7 * 24 * time.Hour},
|
||||
{"1month", "1month", 30 * 24 * time.Hour},
|
||||
{"1year", "1year", 365 * 24 * time.Hour},
|
||||
{"hour (no number)", "hour", time.Hour},
|
||||
{"day (no number)", "day", 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseRecurrencePattern(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse duration: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,74 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ParseRecurrencePattern converts "1w", "2d", "1m" to time.Duration
|
||||
// ParseRecurrencePattern converts duration strings to time.Duration
|
||||
// Supports: 1d, 2w, 3m, 1y, 5min, 5hrs, 30sec
|
||||
// Also supports: days, weeks, months, years, minutes, hours, seconds
|
||||
func ParseRecurrencePattern(pattern string) (time.Duration, error) {
|
||||
if len(pattern) < 2 {
|
||||
return 0, fmt.Errorf("invalid recurrence pattern: %s", pattern)
|
||||
if len(pattern) == 0 {
|
||||
return 0, fmt.Errorf("empty duration pattern")
|
||||
}
|
||||
|
||||
numStr := pattern[:len(pattern)-1]
|
||||
unit := pattern[len(pattern)-1]
|
||||
// Handle word-based durations (hour, hours, day, days, etc.)
|
||||
wordDurations := map[string]time.Duration{
|
||||
"second": time.Second, "seconds": time.Second, "sec": time.Second,
|
||||
"minute": time.Minute, "minutes": time.Minute, "min": time.Minute,
|
||||
"hour": time.Hour, "hours": time.Hour, "hrs": time.Hour, "hr": time.Hour,
|
||||
"day": 24 * time.Hour, "days": 24 * time.Hour,
|
||||
"week": 7 * 24 * time.Hour, "weeks": 7 * 24 * time.Hour,
|
||||
"month": 30 * 24 * time.Hour, "months": 30 * 24 * time.Hour,
|
||||
"year": 365 * 24 * time.Hour, "years": 365 * 24 * time.Hour,
|
||||
}
|
||||
|
||||
if duration, ok := wordDurations[pattern]; ok {
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// Find where number ends and unit begins
|
||||
splitIdx := -1
|
||||
for i, ch := range pattern {
|
||||
if (ch < '0' || ch > '9') && ch != '-' {
|
||||
splitIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if splitIdx == -1 || splitIdx == 0 {
|
||||
return 0, fmt.Errorf("invalid duration pattern: %s", pattern)
|
||||
}
|
||||
|
||||
numStr := pattern[:splitIdx]
|
||||
unit := pattern[splitIdx:]
|
||||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number in pattern: %s", pattern)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 'd':
|
||||
return time.Duration(num) * 24 * time.Hour, nil
|
||||
case 'w':
|
||||
return time.Duration(num) * 7 * 24 * time.Hour, nil
|
||||
case 'm':
|
||||
// Approximate: 30 days
|
||||
return time.Duration(num) * 30 * 24 * time.Hour, nil
|
||||
case 'y':
|
||||
// Approximate: 365 days
|
||||
return time.Duration(num) * 365 * 24 * time.Hour, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown unit: %c (use d/w/m/y)", unit)
|
||||
// Single letter units
|
||||
if len(unit) == 1 {
|
||||
switch unit {
|
||||
case "d":
|
||||
return time.Duration(num) * 24 * time.Hour, nil
|
||||
case "w":
|
||||
return time.Duration(num) * 7 * 24 * time.Hour, nil
|
||||
case "m":
|
||||
// Approximate: 30 days
|
||||
return time.Duration(num) * 30 * 24 * time.Hour, nil
|
||||
case "y":
|
||||
// Approximate: 365 days
|
||||
return time.Duration(num) * 365 * 24 * time.Hour, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown unit: %s (use d/w/m/y)", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-character units
|
||||
if baseDuration, ok := wordDurations[unit]; ok {
|
||||
return time.Duration(num) * baseDuration, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unknown duration unit: %s", unit)
|
||||
}
|
||||
|
||||
// FormatRecurrenceDuration converts time.Duration back to "1w", "2d" format
|
||||
|
||||
Reference in New Issue
Block a user