04fa9222d8
Add 33 new test functions covering annotations, undo system, history formatting, relative date display, and weekday parsing pipeline. Fix ISO date parsing to use ParseInLocation instead of Parse to respect the parser's timezone context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
382 lines
11 KiB
Go
382 lines
11 KiB
Go
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 TestSplitDateTime(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
|
||
wantDate string
|
||
wantTime string
|
||
wantHas bool
|
||
}{
|
||
{"plain weekday", "mon", "mon", "", false},
|
||
{"plain date", "2026-01-15", "2026-01-15", "", false},
|
||
{"weekday+HHMM", "mon:0800", "mon", "0800", true},
|
||
{"weekday+HH:MM", "mon:15:35", "mon", "15:35", true},
|
||
{"date+HHMM", "21jan:1430", "21jan", "1430", true},
|
||
{"just time HH:MM", "15:35", "", "15:35", true},
|
||
{"tomorrow+time", "tomorrow:0800", "tomorrow", "0800", true},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
dateStr, timeStr, hasTime := parser.splitDateTime(tt.input)
|
||
if hasTime != tt.wantHas {
|
||
t.Errorf("hasTime = %v, want %v", hasTime, tt.wantHas)
|
||
}
|
||
if hasTime {
|
||
if dateStr != tt.wantDate {
|
||
t.Errorf("dateStr = %q, want %q", dateStr, tt.wantDate)
|
||
}
|
||
if timeStr != tt.wantTime {
|
||
t.Errorf("timeStr = %q, want %q", timeStr, tt.wantTime)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseISODate(t *testing.T) {
|
||
// Use a non-UTC timezone to verify ISO dates respect the parser's location
|
||
loc := time.FixedZone("UTC+10", 10*60*60)
|
||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, loc)
|
||
parser := NewDateParser(base, time.Monday)
|
||
|
||
result, err := parser.ParseDate("2026-02-20")
|
||
if err != nil {
|
||
t.Fatalf("Failed to parse ISO date: %v", err)
|
||
}
|
||
|
||
expected := time.Date(2026, 2, 20, 0, 0, 0, 0, loc)
|
||
if !result.Equal(expected) {
|
||
t.Errorf("Expected %v, got %v", expected, result)
|
||
}
|
||
if result.Location() != loc {
|
||
t.Errorf("Expected location %v, got %v", loc, result.Location())
|
||
}
|
||
}
|
||
|
||
func TestParseDateInvalid(t *testing.T) {
|
||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||
parser := NewDateParser(base, time.Monday)
|
||
|
||
invalids := []string{
|
||
"notadate",
|
||
"xyz123",
|
||
"",
|
||
"32jan",
|
||
"feb30",
|
||
}
|
||
|
||
for _, input := range invalids {
|
||
t.Run(input, func(t *testing.T) {
|
||
_, err := parser.ParseDate(input)
|
||
if err == nil && input != "" {
|
||
// Some of these might parse as durations or keywords
|
||
// but truly invalid ones should error
|
||
t.Logf("ParseDate(%q) did not error (may be valid as keyword/duration)", input)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNextWeekday_Exhaustive(t *testing.T) {
|
||
// Test all 7 starting days × 7 target days
|
||
// Mon Jan 5 2026
|
||
monday := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||
|
||
allDays := []time.Weekday{
|
||
time.Sunday, time.Monday, time.Tuesday, time.Wednesday,
|
||
time.Thursday, time.Friday, time.Saturday,
|
||
}
|
||
|
||
for fromOffset := 0; fromOffset < 7; fromOffset++ {
|
||
from := monday.AddDate(0, 0, fromOffset)
|
||
parser := NewDateParser(from, time.Monday)
|
||
|
||
for _, target := range allDays {
|
||
t.Run(from.Weekday().String()+"_to_"+target.String(), func(t *testing.T) {
|
||
result := parser.nextWeekday(target)
|
||
|
||
// Must land on the correct weekday
|
||
if result.Weekday() != target {
|
||
t.Errorf("weekday = %v, want %v", result.Weekday(), target)
|
||
}
|
||
|
||
// Must be 1-7 days in the future
|
||
fromMidnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||
days := int(result.Sub(fromMidnight).Hours() / 24)
|
||
if days < 1 || days > 7 {
|
||
t.Errorf("days ahead = %d, want 1-7 (from %v to %v)", days, from, result)
|
||
}
|
||
|
||
// Same weekday should always be 7 days ahead
|
||
if from.Weekday() == target && days != 7 {
|
||
t.Errorf("same weekday should be 7 days, got %d", days)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|