1use crate::usage::Window;
20
21pub const MARGIN: f64 = 5.0;
25
26pub const TOO_EARLY: f64 = 0.03;
29
30#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
32pub struct Pace {
33 pub expected: f64,
36 pub delta: f64,
39 #[serde(flatten)]
40 pub standing: Standing,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
45#[serde(tag = "standing", rename_all = "snake_case")]
46pub enum Standing {
47 Under,
49 Even,
51 Over { runs_out_in: i64 },
54}
55
56pub fn of(
64 used: f64,
65 resets_at: Option<i64>,
66 length_seconds: Option<i64>,
67 at: i64,
68 now: i64,
69) -> Option<Pace> {
70 let length = length_seconds.filter(|length| *length > 0)?;
71 let resets_at = resets_at?;
72 let at = at.min(now);
73 let left = resets_at - at;
74 if resets_at <= now || left > length || used >= 100.0 {
75 return None;
76 }
77 let gone = length - left;
78 let part = gone as f64 / length as f64;
79 if part < TOO_EARLY {
80 return None;
81 }
82 let used = used.max(0.0);
83 let expected = part * 100.0;
84 let delta = used - expected;
85 let standing = if delta > MARGIN {
86 let from_reading = ((100.0 - used) * gone as f64 / used) as i64;
90 Standing::Over {
91 runs_out_in: (from_reading - (now - at)).max(0),
92 }
93 } else if delta < -MARGIN {
94 Standing::Under
95 } else {
96 Standing::Even
97 };
98 Some(Pace {
99 expected,
100 delta,
101 standing,
102 })
103}
104
105impl Window {
106 pub fn pace(&self, at: i64, now: i64) -> Option<Pace> {
108 of(self.percent, self.resets_at, self.length_seconds, at, now)
109 }
110}
111
112pub fn first_to_run_out<T>(
115 limits: impl IntoIterator<Item = (T, Option<Pace>)>,
116) -> Option<(T, i64)> {
117 limits
118 .into_iter()
119 .filter_map(|(limit, pace)| match pace?.standing {
120 Standing::Over { runs_out_in } => Some((limit, runs_out_in)),
121 Standing::Under | Standing::Even => None,
122 })
123 .min_by_key(|(_, runs_out_in)| *runs_out_in)
124}
125
126#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum Lasts<'a> {
129 RunsOut { limit: &'a Window, in_seconds: i64 },
131 UntilReset { in_seconds: i64 },
133}
134
135impl Lasts<'_> {
136 pub fn seconds(self) -> i64 {
137 match self {
138 Lasts::RunsOut { in_seconds, .. } | Lasts::UntilReset { in_seconds } => in_seconds,
139 }
140 }
141}
142
143pub fn lasts(windows: &[Window], at: i64, now: i64) -> Option<Lasts<'_>> {
147 let paced = windows.iter().map(|w| (w, w.pace(at, now)));
148 if let Some((limit, in_seconds)) = first_to_run_out(paced) {
149 return Some(Lasts::RunsOut { limit, in_seconds });
150 }
151 until_reset(windows, now)
152}
153
154pub fn until_reset(windows: &[Window], now: i64) -> Option<Lasts<'_>> {
157 windows
158 .iter()
159 .filter_map(|w| w.resets_at.map(|at| at - now))
160 .filter(|left| *left > 0)
161 .min()
162 .map(|in_seconds| Lasts::UntilReset { in_seconds })
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 const NOW: i64 = 1_789_935_000;
170 const HOUR: i64 = 3_600;
171 const DAY: i64 = 86_400;
172 const WEEK: i64 = 7 * DAY;
173
174 fn pace(used: f64, gone: i64, length: i64) -> Option<Pace> {
176 of(used, Some(NOW + length - gone), Some(length), NOW, NOW)
177 }
178
179 fn window(kind: &str, used: f64, gone: i64, length: i64) -> Window {
180 Window {
181 kind: kind.into(),
182 scope: None,
183 severity: None,
184 percent: used,
185 resets_at: Some(NOW + length - gone),
186 is_active: true,
187 length_seconds: Some(length),
188 }
189 }
190
191 #[test]
194 fn a_limit_is_measured_against_the_part_of_its_window_gone_by() {
195 let half = pace(50.0, WEEK / 2, WEEK).unwrap();
196 assert!((half.expected - 50.0).abs() < 1e-9);
197 assert!(half.delta.abs() < 1e-9);
198 assert_eq!(half.standing, Standing::Even);
199
200 let gone = 10 * HOUR + 18 * 60;
201 let busy = pace(33.0, gone, WEEK).unwrap();
202 assert!((busy.expected - 6.13).abs() < 0.01, "{}", busy.expected);
203 assert!(matches!(busy.standing, Standing::Over { .. }));
204
205 assert_eq!(
206 pace(10.0, WEEK / 2, WEEK).unwrap().standing,
207 Standing::Under
208 );
209 assert_eq!(
210 pace(54.9, WEEK / 2, WEEK).unwrap().standing,
211 Standing::Even,
212 "within the margin"
213 );
214 assert_eq!(
215 pace(44.9, WEEK / 2, WEEK).unwrap().standing,
216 Standing::Under
217 );
218 }
219
220 #[test]
223 fn a_limit_faster_than_even_says_when_it_runs_out() {
224 let gone = 10 * HOUR;
225 let Standing::Over { runs_out_in } = pace(33.0, gone, WEEK).unwrap().standing else {
226 panic!("faster than even");
227 };
228 let at_its_rate = (67.0 * gone as f64 / 33.0) as i64;
229 assert_eq!(runs_out_in, at_its_rate);
230 assert!(runs_out_in < WEEK - gone, "before the reset");
231
232 let Standing::Over { runs_out_in } = pace(55.1, 5 * HOUR / 2, 5 * HOUR).unwrap().standing
234 else {
235 panic!("faster than even");
236 };
237 assert!(runs_out_in < 5 * HOUR / 2, "{runs_out_in}");
238 }
239
240 #[test]
244 fn a_pace_is_said_only_where_it_means_something() {
245 assert_eq!(of(40.0, None, Some(WEEK), NOW, NOW), None);
246 assert_eq!(of(40.0, Some(NOW + DAY), None, NOW, NOW), None);
247 assert_eq!(of(40.0, Some(NOW + DAY), Some(0), NOW, NOW), None);
248 assert_eq!(of(40.0, Some(NOW), Some(WEEK), NOW, NOW), None);
249 assert_eq!(of(40.0, Some(NOW - 60), Some(WEEK), NOW, NOW), None);
250 assert_eq!(of(40.0, Some(NOW + WEEK + 60), Some(WEEK), NOW, NOW), None);
251 assert_eq!(pace(100.0, DAY, WEEK), None, "used up says so itself");
252 assert_eq!(pace(104.0, DAY, WEEK), None);
253
254 let early = (TOO_EARLY * (5 * HOUR) as f64) as i64;
255 assert_eq!(pace(20.0, early - 1, 5 * HOUR), None);
256 assert!(pace(20.0, early + 1, 5 * HOUR).is_some());
257 }
258
259 #[test]
264 fn a_readings_pace_is_its_own_and_only_counts_down() {
265 let read = NOW - HOUR;
266 let resets = NOW + WEEK - 11 * HOUR;
267 let then = of(33.0, Some(resets), Some(WEEK), read, read).unwrap();
268 let later = of(33.0, Some(resets), Some(WEEK), read, NOW).unwrap();
269 assert_eq!(later.expected, then.expected);
270 assert_eq!(later.delta, then.delta);
271 let (Standing::Over { runs_out_in: was }, Standing::Over { runs_out_in: is }) =
272 (then.standing, later.standing)
273 else {
274 panic!("over pace both times");
275 };
276 assert_eq!(is, was - HOUR);
277
278 let long_ago = of(90.0, Some(NOW + 3 * DAY), Some(WEEK), NOW - 3 * DAY, NOW).unwrap();
279 assert_eq!(
280 long_ago.standing,
281 Standing::Over { runs_out_in: 0 },
282 "projected to have run out already: about to, as far as anyone knows"
283 );
284 assert_eq!(
285 of(33.0, Some(NOW - 1), Some(WEEK), NOW - DAY, NOW),
286 None,
287 "it has reset since it was read"
288 );
289 assert_eq!(
290 of(33.0, Some(resets), Some(WEEK), NOW + HOUR, NOW),
291 of(33.0, Some(resets), Some(WEEK), NOW, NOW),
292 "a reading from a clock ahead of this one counts as taken now"
293 );
294 }
295
296 #[test]
298 fn an_untouched_limit_is_under_pace() {
299 assert_eq!(pace(0.0, DAY, WEEK).unwrap().standing, Standing::Under);
300 assert_eq!(pace(-1.0, DAY, WEEK).unwrap().standing, Standing::Under);
301 }
302
303 #[test]
307 fn an_account_lasts_until_its_first_limit_runs_out_or_resets() {
308 let session = window("session", 20.0, 4 * HOUR, 5 * HOUR);
309 let week = window("weekly_all", 33.0, 10 * HOUR, WEEK);
310 let busy = [session.clone(), week];
311 let Some(Lasts::RunsOut { limit, in_seconds }) = lasts(&busy, NOW, NOW) else {
312 panic!("the week runs out");
313 };
314 assert_eq!(limit.kind, "weekly_all");
315 assert_eq!(in_seconds, (67.0 * (10 * HOUR) as f64 / 33.0) as i64);
316
317 let calm = window("weekly_all", 5.0, 3 * DAY, WEEK);
318 assert_eq!(
319 lasts(&[session.clone(), calm], NOW, NOW),
320 Some(Lasts::UntilReset { in_seconds: HOUR })
321 );
322
323 let mut unknown = window("weekly_all", 5.0, 3 * DAY, WEEK);
324 unknown.resets_at = None;
325 assert_eq!(lasts(&[unknown], NOW, NOW), None);
326 assert_eq!(
327 until_reset(&busy, NOW),
328 Some(Lasts::UntilReset { in_seconds: HOUR }),
329 "the week's pace says nothing of an account nobody is using"
330 );
331 }
332
333 #[test]
335 fn the_first_limit_to_run_out_is_the_soonest() {
336 let paces = [
337 ("week", pace(40.0, DAY, WEEK)),
338 ("session", pace(90.0, 2 * HOUR, 5 * HOUR)),
339 ("calm", pace(1.0, DAY, WEEK)),
340 ("unknown", None),
341 ];
342 let (first, _) = first_to_run_out(paces).unwrap();
343 assert_eq!(first, "session");
344 assert_eq!(first_to_run_out([("calm", pace(1.0, DAY, WEEK))]), None);
345 }
346
347 #[test]
350 fn a_pace_reads_in_json_as_its_figures_and_standing() {
351 let over = Pace {
352 expected: 6.0,
353 delta: 27.0,
354 standing: Standing::Over {
355 runs_out_in: 72_000,
356 },
357 };
358 assert_eq!(
359 serde_json::to_value(over).unwrap(),
360 serde_json::json!({
361 "expected": 6.0,
362 "delta": 27.0,
363 "standing": "over",
364 "runs_out_in": 72_000,
365 })
366 );
367 let under = Pace {
368 expected: 50.0,
369 delta: -40.0,
370 standing: Standing::Under,
371 };
372 assert_eq!(
373 serde_json::to_value(under).unwrap(),
374 serde_json::json!({"expected": 50.0, "delta": -40.0, "standing": "under"})
375 );
376 }
377}