Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion rrule.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,6 @@ func buildRRule(arg ROption) RRule {
}
}
}
sort.Sort(timeSlice(r.timeset))
}

r.Options = arg
Expand Down
140 changes: 140 additions & 0 deletions rrule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4010,3 +4010,143 @@ func iterateNum(iter Next, num int) (last time.Time) {
}
return last
}

// TestBySetPosWithNonSortedByhourByminute tests that BYSETPOS correctly
// references positions in the timeset when BYHOUR and BYMINUTE values
// are specified in non-chronological order.
// This is a regression test for the bug where the timeset was incorrectly
// sorted, causing BYSETPOS to select wrong time combinations.
//
// Example: BYHOUR=3,6 BYMINUTE=45,15 should generate timeset:
// [03:45, 03:15, 06:45, 06:15]
// BYSETPOS=1,4 should select positions 1 and 4:
// 03:45 (position 1) and 06:15 (position 4)
//
// Before the fix, the timeset was sorted to [03:15, 03:45, 06:15, 06:45],
// causing BYSETPOS=1,4 to incorrectly select 03:15 and 06:45.
func TestBySetPosWithNonSortedByhourByminute(t *testing.T) {
r, err := NewRRule(ROption{
Freq: DAILY,
Interval: 1,
Count: 8,
Dtstart: time.Date(2025, 3, 21, 0, 0, 0, 0, time.UTC),
Byhour: []int{3, 6},
Byminute: []int{45, 15},
Bysetpos: []int{1, 4},
})
if err != nil {
t.Fatalf("failed to create RRule: %v", err)
}

value := r.All()

// Expected: each day should have 03:45 and 06:15 (not 03:15 and 06:45)
// The timeset is built as [03:45, 03:15, 06:45, 06:15] based on BYHOUR/BYMINUTE order
// BYSETPOS=1,4 selects position 1 (03:45) and position 4 (06:15)
want := []time.Time{
time.Date(2025, 3, 21, 3, 45, 0, 0, time.UTC),
time.Date(2025, 3, 21, 6, 15, 0, 0, time.UTC),
time.Date(2025, 3, 22, 3, 45, 0, 0, time.UTC),
time.Date(2025, 3, 22, 6, 15, 0, 0, time.UTC),
time.Date(2025, 3, 23, 3, 45, 0, 0, time.UTC),
time.Date(2025, 3, 23, 6, 15, 0, 0, time.UTC),
time.Date(2025, 3, 24, 3, 45, 0, 0, time.UTC),
time.Date(2025, 3, 24, 6, 15, 0, 0, time.UTC),
}

if !timesEqual(value, want) {
t.Errorf("BYSETPOS with non-sorted BYHOUR/BYMINUTE failed.\ngot: %v\nwant: %v", value, want)
}
}

// TestBySetPosPreservesTimesetOrder verifies that the timeset order
// is preserved based on the order of BYHOUR and BYMINUTE values,
// not sorted chronologically. This ensures BYSETPOS works correctly.
//
// The timeset is generated by iterating BYHOUR then BYMINUTE:
// for hour in BYHOUR:
// for minute in BYMINUTE:
// timeset.append(hour:minute)
//
// For example, BYHOUR=[6,3] BYMINUTE=[30,0] generates:
// [06:30, 06:00, 03:30, 03:00]
// NOT the sorted order [03:00, 03:30, 06:00, 06:30]
func TestBySetPosPreservesTimesetOrder(t *testing.T) {
tests := []struct {
name string
byhour []int
byminute []int
bysetpos []int
wantHM [][2]int // expected hour:minute pairs
}{
{
// timeset: [06:30, 06:00, 03:30, 03:00]
// BYSETPOS=1,4 selects positions 1 and 4: 06:30 and 03:00
// Results are returned chronologically: 03:00, then 06:30
name: "reverse order hours and minutes",
byhour: []int{6, 3},
byminute: []int{30, 0},
bysetpos: []int{1, 4},
wantHM: [][2]int{{3, 0}, {6, 30}},
},
{
// timeset: [12:15, 12:45, 08:15, 08:45]
// BYSETPOS=2,3 selects positions 2 and 3: 12:45 and 08:15
// Results are returned chronologically: 08:15, then 12:45
name: "mixed order",
byhour: []int{12, 8},
byminute: []int{15, 45},
bysetpos: []int{2, 3},
wantHM: [][2]int{{8, 15}, {12, 45}},
},
{
// timeset: [09:45, 09:30, 09:15, 09:00]
// BYSETPOS=1,4 selects positions 1 and 4: 09:45 and 09:00
// Results are returned chronologically: 09:00, then 09:45
name: "single hour multiple minutes",
byhour: []int{9},
byminute: []int{45, 30, 15, 0},
bysetpos: []int{1, 4},
wantHM: [][2]int{{9, 0}, {9, 45}},
},
{
// timeset: [03:45, 03:15, 06:45, 06:15]
// BYSETPOS=-4,-1 selects positions -4 (=1) and -1 (=4): 03:45 and 06:15
// Results are returned chronologically: 03:45, then 06:15
name: "negative bysetpos",
byhour: []int{3, 6},
byminute: []int{45, 15},
bysetpos: []int{-4, -1},
wantHM: [][2]int{{3, 45}, {6, 15}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := NewRRule(ROption{
Freq: DAILY,
Count: len(tt.wantHM),
Dtstart: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
Byhour: tt.byhour,
Byminute: tt.byminute,
Bysetpos: tt.bysetpos,
})
if err != nil {
t.Fatalf("failed to create RRule: %v", err)
}

value := r.All()
if len(value) != len(tt.wantHM) {
t.Fatalf("got %d results, want %d", len(value), len(tt.wantHM))
}

for i, v := range value {
gotH, gotM := v.Hour(), v.Minute()
wantH, wantM := tt.wantHM[i][0], tt.wantHM[i][1]
if gotH != wantH || gotM != wantM {
t.Errorf("result[%d]: got %02d:%02d, want %02d:%02d", i, gotH, gotM, wantH, wantM)
}
}
})
}
}