feat(quando-tn3): implement relative date parsing

Add ParseRelative() and ParseRelativeWithClock() for parsing relative
date expressions like "today", "tomorrow", "+2 days", "-1 week", etc.

Features:
- Keywords: today, tomorrow, yesterday (case-insensitive)
- Relative offsets: +/-N <unit> format
- Supported units: day(s), week(s), month(s), quarter(s), year(s)
- All results return at 00:00:00 in local timezone
- Comprehensive error handling with ErrInvalidFormat

Implementation:
- parse.go: Added ParseRelative(), ParseRelativeWithClock(), parseUnitString()
- parse_test.go: Added 38 test cases covering all expressions and errors
- example_test.go: Added 4 example functions demonstrating usage

Test results:
- 100% code coverage for all ParseRelative functions
- Benchmarks: ~67ns (keywords), ~563ns (offsets) - well under <20µs target
- All existing tests pass

Complex expressions like "next monday" or "start of month" are out of
scope for Phase 1 and documented for future implementation.
This commit is contained in:
Oliver Jakoubek 2026-02-11 20:01:35 +01:00
commit 8c9e0e725a
4 changed files with 518 additions and 1 deletions

View file

@ -524,3 +524,334 @@ func TestParseWithLayoutImmutability(t *testing.T) {
t.Error("Add should return a new Date instance")
}
}
func TestParseRelativeWithClock(t *testing.T) {
// Fixed clock for deterministic testing
fixedTime := time.Date(2026, 2, 15, 14, 30, 45, 0, time.UTC) // Saturday, Feb 15, 2:30 PM
clock := NewFixedClock(fixedTime)
tests := []struct {
name string
input string
expectedYear int
expectedMonth time.Month
expectedDay int
}{
// Simple keywords
{
name: "today",
input: "today",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 15,
},
{
name: "tomorrow",
input: "tomorrow",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 16,
},
{
name: "yesterday",
input: "yesterday",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 14,
},
// Case-insensitive keywords
{
name: "TODAY (uppercase)",
input: "TODAY",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 15,
},
{
name: "Tomorrow (mixed case)",
input: "Tomorrow",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 16,
},
// Relative offsets - days
{
name: "+1 day",
input: "+1 day",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 16,
},
{
name: "+2 days",
input: "+2 days",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 17,
},
{
name: "-1 day",
input: "-1 day",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 14,
},
{
name: "+7 days",
input: "+7 days",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 22,
},
// Relative offsets - weeks
{
name: "+1 week",
input: "+1 week",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 22,
},
{
name: "+2 weeks",
input: "+2 weeks",
expectedYear: 2026,
expectedMonth: time.March,
expectedDay: 1,
},
{
name: "-1 week",
input: "-1 week",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 8,
},
// Relative offsets - months
{
name: "+1 month",
input: "+1 month",
expectedYear: 2026,
expectedMonth: time.March,
expectedDay: 15,
},
{
name: "+3 months",
input: "+3 months",
expectedYear: 2026,
expectedMonth: time.May,
expectedDay: 15,
},
{
name: "-1 month",
input: "-1 month",
expectedYear: 2026,
expectedMonth: time.January,
expectedDay: 15,
},
// Relative offsets - quarters
{
name: "+1 quarter",
input: "+1 quarter",
expectedYear: 2026,
expectedMonth: time.May,
expectedDay: 15,
},
{
name: "-1 quarter",
input: "-1 quarter",
expectedYear: 2025,
expectedMonth: time.November,
expectedDay: 15,
},
// Relative offsets - years
{
name: "+1 year",
input: "+1 year",
expectedYear: 2027,
expectedMonth: time.February,
expectedDay: 15,
},
{
name: "-1 year",
input: "-1 year",
expectedYear: 2025,
expectedMonth: time.February,
expectedDay: 15,
},
// Whitespace variations
{
name: "leading whitespace",
input: " today",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 15,
},
{
name: "trailing whitespace",
input: "+2 days ",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 17,
},
{
name: "extra spaces between parts",
input: "+2 days",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 17,
},
// Case-insensitive units
{
name: "+2 DAYS (uppercase)",
input: "+2 DAYS",
expectedYear: 2026,
expectedMonth: time.February,
expectedDay: 17,
},
{
name: "+1 Month (mixed case)",
input: "+1 Month",
expectedYear: 2026,
expectedMonth: time.March,
expectedDay: 15,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
date, err := ParseRelativeWithClock(tt.input, clock)
if err != nil {
t.Fatalf("ParseRelativeWithClock(%q) unexpected error: %v", tt.input, err)
}
tm := date.Time()
if tm.Year() != tt.expectedYear {
t.Errorf("Year = %d, want %d", tm.Year(), tt.expectedYear)
}
if tm.Month() != tt.expectedMonth {
t.Errorf("Month = %v, want %v", tm.Month(), tt.expectedMonth)
}
if tm.Day() != tt.expectedDay {
t.Errorf("Day = %d, want %d", tm.Day(), tt.expectedDay)
}
// Verify time is 00:00:00 (StartOf(Days) behavior)
if tm.Hour() != 0 || tm.Minute() != 0 || tm.Second() != 0 {
t.Errorf("Time should be 00:00:00, got %02d:%02d:%02d", tm.Hour(), tm.Minute(), tm.Second())
}
// Verify default language is set
if date.lang != EN {
t.Errorf("lang = %v, want EN", date.lang)
}
})
}
}
func TestParseRelativeErrors(t *testing.T) {
clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC))
tests := []struct {
name string
input string
}{
{"empty string", ""},
{"whitespace only", " "},
{"unknown keyword", "now"},
{"unknown keyword 2", "currently"},
{"invalid format - no sign", "2 days"},
{"invalid format - wrong parts", "+2"},
{"invalid format - too many parts", "+2 days ago"},
{"invalid offset - not a number", "+abc days"},
{"invalid offset - float", "+1.5 days"},
{"invalid unit", "+2 fortnights"},
{"invalid unit 2", "+1 decade"},
{"invalid unit 3", "+3 hours"}, // hours not supported in Phase 1
{"sign without number", "+ days"},
{"number without unit", "+2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseRelativeWithClock(tt.input, clock)
if err == nil {
t.Errorf("ParseRelativeWithClock(%q) expected error, got nil", tt.input)
}
// Verify error wraps ErrInvalidFormat
if !errors.Is(err, ErrInvalidFormat) {
t.Errorf("error should wrap ErrInvalidFormat, got: %v", err)
}
})
}
}
func TestParseRelative(t *testing.T) {
// Test the production function (uses system clock)
// Just verify it doesn't error on valid inputs
validInputs := []string{
"today",
"tomorrow",
"yesterday",
"+1 day",
"-2 weeks",
"+3 months",
}
for _, input := range validInputs {
t.Run(input, func(t *testing.T) {
_, err := ParseRelative(input)
if err != nil {
t.Errorf("ParseRelative(%q) unexpected error: %v", input, err)
}
})
}
}
func TestParseRelativeImmutability(t *testing.T) {
clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC))
// Parse the same expression twice
date1, err1 := ParseRelativeWithClock("tomorrow", clock)
date2, err2 := ParseRelativeWithClock("tomorrow", clock)
if err1 != nil || err2 != nil {
t.Fatalf("ParseRelativeWithClock failed: %v, %v", err1, err2)
}
// Modify date1
modified := date1.Add(5, Days)
// Verify date2 is unchanged
if date2.Unix() != date1.Unix() {
t.Error("date2 should not be affected by operations on date1")
}
if modified.Unix() == date1.Unix() {
t.Error("Add should return a new Date instance")
}
}
func BenchmarkParseRelativeKeyword(b *testing.B) {
clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ParseRelativeWithClock("today", clock)
}
}
func BenchmarkParseRelativeOffset(b *testing.B) {
clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ParseRelativeWithClock("+2 days", clock)
}
}