Coverage for app/backend/src/tests/test_event_recurrence.py: 100%

96 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:40 +0000

1from datetime import UTC, date, datetime, timedelta 

2 

3from couchers.event_recurrence import ( 

4 get_future_occurrences, 

5 make_daily_rrule, 

6 make_every_nth_week_rrule, 

7 make_monthly_rrule, 

8 schedule_occurrences, 

9) 

10from tests.fixtures.timewarp import FrozenTimewarp 

11 

12 

13def test_make_every_nth_week_rrule_weekly() -> None: 

14 start = date(2000, 1, 1) 

15 rrule = make_every_nth_week_rrule(start, n=1) 

16 

17 occurrences = get_future_occurrences(start, rrule) 

18 assert next(occurrences) == start 

19 assert next(occurrences) == date(2000, 1, 8) 

20 assert next(occurrences) == date(2000, 1, 15) 

21 

22 

23def test_make_every_nth_week_rrule_biweekly() -> None: 

24 start = date(2000, 1, 1) 

25 rrule = make_every_nth_week_rrule(start, n=2) 

26 

27 occurrences = get_future_occurrences(start, rrule) 

28 assert next(occurrences) == start 

29 assert next(occurrences) == date(2000, 1, 15) 

30 assert next(occurrences) == date(2000, 1, 29) 

31 

32 

33def test_make_daily_rrule() -> None: 

34 start = date(2000, 1, 1) 

35 rrule = make_daily_rrule(start) 

36 

37 occurrences = get_future_occurrences(start, rrule) 

38 assert next(occurrences) == start 

39 assert next(occurrences) == date(2000, 1, 2) 

40 assert next(occurrences) == date(2000, 1, 3) 

41 

42 

43def test_make_daily_rrule_with_end_date() -> None: 

44 start = date(2000, 1, 1) 

45 rrule = make_daily_rrule(start, end_date=date(2000, 1, 2)) 

46 

47 assert list(get_future_occurrences(start, rrule)) == [start, date(2000, 1, 2)] 

48 

49 

50def test_make_monthly_rrule() -> None: 

51 start = date(2000, 1, 1) 

52 rrule = make_monthly_rrule(start) 

53 

54 occurrences = get_future_occurrences(start, rrule) 

55 assert next(occurrences) == start 

56 assert next(occurrences) == date(2000, 2, 1) 

57 assert next(occurrences) == date(2000, 3, 1) 

58 

59 

60def test_make_monthly_rrule_with_end_date() -> None: 

61 start = date(2000, 1, 1) 

62 rrule = make_monthly_rrule(start, end_date=date(2000, 2, 1)) 

63 

64 assert list(get_future_occurrences(start, rrule)) == [start, date(2000, 2, 1)] 

65 

66 

67def test_get_future_occurrences_boundaries() -> None: 

68 start = date(2000, 1, 1) 

69 rrule = make_every_nth_week_rrule(start, n=1) 

70 

71 # from_date a week before the first occurrence: shouldn't return anything from the week prior 

72 occurrences = get_future_occurrences(start - timedelta(days=8), rrule) 

73 assert next(occurrences) == start 

74 assert next(occurrences) == date(2000, 1, 8) 

75 assert next(occurrences) == date(2000, 1, 15) 

76 

77 # inclusive of from_date exactly on an occurrence 

78 occurrences = get_future_occurrences(start, rrule) 

79 assert next(occurrences) == start 

80 assert next(occurrences) == date(2000, 1, 8) 

81 assert next(occurrences) == date(2000, 1, 15) 

82 

83 # After the first occurrence 

84 occurrences = get_future_occurrences(date(2000, 1, 2), rrule) 

85 assert next(occurrences) == date(2000, 1, 8) 

86 assert next(occurrences) == date(2000, 1, 15) 

87 

88 

89def test_get_future_occurrences_bounded_rule_terminates() -> None: 

90 start = date(2000, 1, 1) 

91 rrule = make_every_nth_week_rrule(start, n=1, end_date=date(2000, 1, 8)) 

92 

93 assert list(get_future_occurrences(start, rrule)) == [start, date(2000, 1, 8)] 

94 

95 

96def test_get_future_occurrences_past_last_occurrence_is_empty() -> None: 

97 start = date(2000, 1, 1) 

98 rrule = make_every_nth_week_rrule(start, n=1, end_date=date(2000, 1, 8)) 

99 

100 assert list(get_future_occurrences(date(2000, 2, 1), rrule)) == [] 

101 

102 

103def test_schedule_occurrences_frequent_rule_returns_more_than_minimum() -> None: 

104 """ 

105 For frequent events, we should schedule all events within the schedule window, 

106 which should exceed the minimum occurrence count (used for infrequent events). 

107 """ 

108 reference_date = date(2000, 1, 1) 

109 rrule = make_daily_rrule(reference_date) 

110 

111 result = schedule_occurrences( 

112 rrule=rrule, 

113 schedule_window=timedelta(weeks=2), 

114 min_occurrences=2, 

115 last_scheduled_date=reference_date - timedelta(days=1), 

116 today=reference_date, 

117 ) 

118 

119 assert len(result) > 2 

120 assert result[0] == reference_date 

121 assert result[-1] <= reference_date + timedelta(weeks=2) 

122 

123 

124def test_schedule_occurrences_infrequent_rule_returns_minimum_occurrences() -> None: 

125 """ 

126 For infrequent events, we should schedule a fixed occurrence count. 

127 """ 

128 reference_date = date(2000, 1, 1) 

129 # monthly, well outside the 2-week schedule window between occurrences 

130 rrule = make_monthly_rrule(reference_date) 

131 

132 result = schedule_occurrences( 

133 rrule=rrule, 

134 schedule_window=timedelta(weeks=2), 

135 min_occurrences=2, 

136 last_scheduled_date=reference_date - timedelta(days=1), 

137 today=reference_date, 

138 ) 

139 

140 assert result == [reference_date, date(2000, 2, 1)] 

141 

142 

143def test_schedule_occurrences_short_rule_returns_fewer_than_minimum() -> None: 

144 """ 

145 When the event's recurrence is about to end, we might schedule fewer than 

146 the minimum ahead-of-time scheduled occurrences. 

147 """ 

148 reference_date = date(2000, 1, 1) 

149 rrule = make_every_nth_week_rrule(reference_date, n=1, end_date=reference_date) 

150 

151 result = schedule_occurrences( 

152 rrule=rrule, 

153 schedule_window=timedelta(weeks=2), 

154 min_occurrences=2, 

155 last_scheduled_date=reference_date - timedelta(days=1), 

156 today=reference_date, 

157 ) 

158 

159 assert result == [reference_date] 

160 

161 

162def test_schedule_occurrences_filters_out_already_scheduled() -> None: 

163 """ 

164 We should never schedule an occurrence on a date we previously scheduled. 

165 """ 

166 

167 reference_date = date(2000, 1, 1) 

168 rrule = make_every_nth_week_rrule(reference_date, n=1) 

169 

170 # last_scheduled_date equal to an occurrence excludes it (strict >) 

171 result = schedule_occurrences( 

172 rrule=rrule, 

173 schedule_window=timedelta(weeks=2), 

174 min_occurrences=2, 

175 last_scheduled_date=reference_date, 

176 today=reference_date, 

177 ) 

178 assert result == [date(2000, 1, 8), date(2000, 1, 15)] 

179 

180 # everything already scheduled 

181 result = schedule_occurrences( 

182 rrule=rrule, 

183 schedule_window=timedelta(weeks=2), 

184 min_occurrences=2, 

185 last_scheduled_date=date(2000, 1, 15), 

186 today=reference_date, 

187 ) 

188 assert result == [] 

189 

190 

191def test_schedule_occurrences_defaults_last_scheduled_date_to_none() -> None: 

192 """ 

193 When nothing has been scheduled yet, nothing should be filtered out. 

194 """ 

195 reference_date = date(2000, 1, 1) 

196 rrule = make_every_nth_week_rrule(reference_date, n=1) 

197 

198 result = schedule_occurrences( 

199 rrule=rrule, 

200 schedule_window=timedelta(weeks=2), 

201 min_occurrences=2, 

202 today=reference_date, 

203 ) 

204 

205 assert result == [reference_date, date(2000, 1, 8), date(2000, 1, 15)] 

206 

207 

208def test_schedule_occurrences_defaults_today_to_now(frozen_timewarp: FrozenTimewarp) -> None: 

209 reference_date = date(2000, 1, 1) 

210 frozen_timewarp.freeze_at(datetime(2000, 1, 1, tzinfo=UTC)) 

211 

212 rrule = make_every_nth_week_rrule(reference_date, n=1) 

213 result = schedule_occurrences( 

214 rrule=rrule, 

215 schedule_window=timedelta(weeks=2), 

216 min_occurrences=2, 

217 last_scheduled_date=reference_date - timedelta(days=1), 

218 ) 

219 

220 assert result[0] == reference_date