@@ -187,3 +187,160 @@ func TestTimeRangeString(t *testing.T) {
187187 })
188188 }
189189}
190+
191+ func TestOvernightTimeRange (t * testing.T ) {
192+ cases := []struct {
193+ name string
194+ input string
195+ want string
196+ }{
197+ {"overnight range" , "23:00-02:00" , "23:00-02:00" },
198+ {"overnight range with seconds" , "22:30:00-04:15:00" , "22:30:00-04:15:00" },
199+ {"regular range" , "09:00-17:00" , "09:00-17:00" },
200+ }
201+
202+ for _ , c := range cases {
203+ t .Run (c .name , func (t * testing.T ) {
204+ timeRange , err := parseTimeRange (c .input )
205+ if err != nil {
206+ t .Errorf ("unexpected error: %v" , err )
207+ return
208+ }
209+
210+ if got := timeRange .String (); got != c .want {
211+ t .Errorf ("got %q, want %q" , got , c .want )
212+ }
213+
214+ if c .input == "23:00-02:00" {
215+ if ! timeRange .overnight {
216+ t .Error ("overnight should be true for 23:00-02:00" )
217+ }
218+
219+ rule := Rule {
220+ timeRange : timeRange ,
221+ dow : Field {all : true },
222+ dom : Field {all : true },
223+ month : Field {all : true },
224+ }
225+
226+ baseTime := time .Date (2024 , 1 , 1 , 0 , 0 , 0 , 0 , time .UTC )
227+ times := []struct {
228+ t time.Time
229+ name string
230+ want bool
231+ }{
232+ {baseTime .Add (- time .Hour ), "23:00" , true },
233+ {baseTime , "00:00" , true },
234+ {baseTime .Add (time .Hour ), "01:00" , true },
235+ {baseTime .Add (2 * time .Hour ), "02:00" , true },
236+ {baseTime .Add (3 * time .Hour ), "03:00" , false },
237+ {baseTime .Add (- 2 * time .Hour ), "22:00" , false },
238+ }
239+
240+ for _ , tc := range times {
241+ if got := rule .matches (tc .t ); got != tc .want {
242+ t .Errorf ("%s: got %v, want %v" , tc .name , got , tc .want )
243+ }
244+ }
245+ }
246+ })
247+ }
248+ }
249+
250+ func TestMatches (t * testing.T ) {
251+ cases := []struct {
252+ name string
253+ rule string
254+ times []time.Time
255+ wantMatch []bool
256+ }{
257+ {
258+ name : "simple range" ,
259+ rule : "09:00-17:00 * * *" ,
260+ times : []time.Time {
261+ time .Date (2024 , 1 , 1 , 8 , 59 , 59 , 0 , time .UTC ), // before range
262+ time .Date (2024 , 1 , 1 , 9 , 0 , 0 , 0 , time .UTC ), // start of range
263+ time .Date (2024 , 1 , 1 , 13 , 0 , 0 , 0 , time .UTC ), // middle
264+ time .Date (2024 , 1 , 1 , 17 , 0 , 0 , 0 , time .UTC ), // end of range
265+ time .Date (2024 , 1 , 1 , 17 , 0 , 1 , 0 , time .UTC ), // after range
266+ },
267+ wantMatch : []bool {false , true , true , true , false },
268+ },
269+ {
270+ name : "overnight range" ,
271+ rule : "23:00-02:00 * * *" ,
272+ times : []time.Time {
273+ time .Date (2024 , 1 , 1 , 22 , 59 , 59 , 0 , time .UTC ), // just before
274+ time .Date (2024 , 1 , 1 , 23 , 0 , 0 , 0 , time .UTC ), // start
275+ time .Date (2024 , 1 , 1 , 23 , 59 , 59 , 0 , time .UTC ), // before midnight
276+ time .Date (2024 , 1 , 2 , 0 , 0 , 0 , 0 , time .UTC ), // midnight
277+ time .Date (2024 , 1 , 2 , 1 , 30 , 0 , 0 , time .UTC ), // middle
278+ time .Date (2024 , 1 , 2 , 2 , 0 , 0 , 0 , time .UTC ), // end
279+ time .Date (2024 , 1 , 2 , 2 , 0 , 1 , 0 , time .UTC ), // just after
280+ },
281+ wantMatch : []bool {false , true , true , true , true , true , false },
282+ },
283+ {
284+ name : "specific days" ,
285+ rule : "10:00-12:00 1,3,5 * *" , // Mon,Wed,Fri
286+ times : []time.Time {
287+ time .Date (2024 , 1 , 1 , 11 , 0 , 0 , 0 , time .UTC ), // Monday
288+ time .Date (2024 , 1 , 2 , 11 , 0 , 0 , 0 , time .UTC ), // Tuesday
289+ time .Date (2024 , 1 , 3 , 11 , 0 , 0 , 0 , time .UTC ), // Wednesday
290+ },
291+ wantMatch : []bool {true , false , true },
292+ },
293+ {
294+ name : "specific months" ,
295+ rule : "* * * 3,6,9,12" , // Mar,Jun,Sep,Dec
296+ times : []time.Time {
297+ time .Date (2024 , 2 , 1 , 11 , 0 , 0 , 0 , time .UTC ), // February
298+ time .Date (2024 , 3 , 1 , 11 , 0 , 0 , 0 , time .UTC ), // March
299+ time .Date (2024 , 4 , 1 , 11 , 0 , 0 , 0 , time .UTC ), // April
300+ },
301+ wantMatch : []bool {false , true , false },
302+ },
303+ {
304+ name : "complex range" ,
305+ rule : "09:00-17:00 1-5 1-7 3,6,9,12" , // weekdays, first week, specific months
306+ times : []time.Time {
307+ time .Date (2024 , 3 , 1 , 13 , 0 , 0 , 0 , time .UTC ), // Fri, Mar 1
308+ time .Date (2024 , 3 , 2 , 13 , 0 , 0 , 0 , time .UTC ), // Sat, Mar 2
309+ time .Date (2024 , 3 , 4 , 13 , 0 , 0 , 0 , time .UTC ), // Mon, Mar 4
310+ time .Date (2024 , 3 , 8 , 13 , 0 , 0 , 0 , time .UTC ), // Fri, Mar 8
311+ time .Date (2024 , 4 , 1 , 13 , 0 , 0 , 0 , time .UTC ), // Mon, Apr 1
312+ },
313+ wantMatch : []bool {true , false , true , false , false },
314+ },
315+ {
316+ name : "all wildcards" ,
317+ rule : "* * * *" ,
318+ times : []time.Time {
319+ time .Date (2024 , 1 , 1 , 0 , 0 , 0 , 0 , time .UTC ),
320+ time .Date (2024 , 6 , 15 , 12 , 30 , 0 , 0 , time .UTC ),
321+ time .Date (2024 , 12 , 31 , 23 , 59 , 59 , 0 , time .UTC ),
322+ },
323+ wantMatch : []bool {true , true , true },
324+ },
325+ }
326+
327+ for _ , c := range cases {
328+ t .Run (c .name , func (t * testing.T ) {
329+ rule , err := parseRule (c .rule )
330+ if err != nil {
331+ t .Fatalf ("failed to parse rule %q: %v" , c .rule , err )
332+ }
333+
334+ if len (c .times ) != len (c .wantMatch ) {
335+ t .Fatal ("times and wantMatch slices must have equal length" )
336+ }
337+
338+ for i , tm := range c .times {
339+ got := rule .matches (tm )
340+ if got != c .wantMatch [i ] {
341+ t .Errorf ("time %v: got %v, want %v" , tm .Format ("2006-01-02 15:04:05" ), got , c .wantMatch [i ])
342+ }
343+ }
344+ })
345+ }
346+ }
0 commit comments