1use crate::context::Context;
33use crate::provider::ProviderId;
34use crate::service::Permit;
35use crate::state::{Key, State};
36use crate::status::Row;
37use crate::usage::{Snapshot, Window, same_reset};
38use crate::{atomic, home};
39use serde::{Deserialize, Serialize};
40use std::cmp::Ordering;
41use std::collections::BTreeMap;
42use std::path::PathBuf;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Threshold(u8);
51
52impl Threshold {
53 pub const LOWEST: u8 = 50;
54 pub const HIGHEST: u8 = 99;
55 pub const DEFAULT: Threshold = Threshold(95);
56
57 pub fn new(percent: u8) -> Option<Threshold> {
59 (Threshold::LOWEST..=Threshold::HIGHEST)
60 .contains(&percent)
61 .then_some(Threshold(percent))
62 }
63
64 pub fn clamped(percent: i64) -> Threshold {
66 let lowest = i64::from(Threshold::LOWEST);
67 let highest = i64::from(Threshold::HIGHEST);
68 Threshold(u8::try_from(percent.clamp(lowest, highest)).unwrap_or(Threshold::DEFAULT.0))
69 }
70
71 pub const fn percent(self) -> u8 {
72 self.0
73 }
74
75 fn reached_by(self, window: &Window, now: i64) -> bool {
76 window.used(now) >= f64::from(self.0)
77 }
78}
79
80impl Default for Threshold {
81 fn default() -> Threshold {
82 Threshold::DEFAULT
83 }
84}
85
86pub const SETTLING_SECONDS: i64 = 300;
92
93pub const RETRY_SECONDS: i64 = 60;
97
98pub const RETRY_MOST_SECONDS: i64 = 900;
101
102pub const ATTEMPTS: u32 = 3;
106
107fn retry_after(waits: u32) -> i64 {
110 (RETRY_SECONDS << waits.saturating_sub(1).min(4)).min(RETRY_MOST_SECONDS)
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct Plan {
116 pub from: Key,
118 pub from_id: String,
119 pub to: Key,
121 pub limit: Window,
123}
124
125impl Plan {
126 pub(crate) fn same(&self, other: &Plan) -> bool {
129 self.from == other.from
130 && self.from_id == other.from_id
131 && self.to == other.to
132 && self.limit.same_limit(&other.limit)
133 && same_reset(
134 self.limit.resets_at.unwrap_or(0),
135 other.limit.resets_at.unwrap_or(0),
136 )
137 }
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub(crate) enum Decision {
143 Stay,
146 Switch(Plan),
147 NoRoom {
149 from: Key,
150 limit: Window,
151 },
152}
153
154#[derive(Debug)]
156pub enum Auto {
157 Idle,
159 Switched {
162 from: String,
163 to: String,
164 limit: Window,
165 adoption: crate::provider::Adoption,
166 },
167 NoRoom { from: String, limit: Window },
170 Skipped {
172 from: String,
173 limit: Window,
174 why: Skip,
175 },
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum Skip {
181 SwitchInterrupted,
183 CustomOauth,
185 Overridden(Vec<String>),
188}
189
190impl Skip {
191 pub fn code(&self) -> &'static str {
193 match self {
194 Skip::SwitchInterrupted => "switch_interrupted",
195 Skip::CustomOauth => "custom_oauth_endpoint",
196 Skip::Overridden(_) => "auth_overridden",
197 }
198 }
199}
200
201fn reached(reading: &Snapshot, threshold: Threshold, now: i64) -> Option<&Window> {
203 reading
204 .windows
205 .iter()
206 .filter(|window| threshold.reached_by(window, now))
207 .min_by(|a, b| {
208 b.used(now)
209 .total_cmp(&a.used(now))
210 .then_with(|| sooner(a.resets_at, b.resets_at))
211 })
212}
213
214fn sooner(a: Option<i64>, b: Option<i64>) -> Ordering {
216 match (a, b) {
217 (Some(a), Some(b)) => a.cmp(&b),
218 (Some(_), None) => Ordering::Less,
219 (None, Some(_)) => Ordering::Greater,
220 (None, None) => Ordering::Equal,
221 }
222}
223
224fn room(
234 candidate: &Row,
235 mine: &Snapshot,
236 limit: &Window,
237 threshold: Threshold,
238 now: i64,
239) -> Option<(f64, f64)> {
240 let theirs = &candidate.usage.as_ref()?.windows;
241 if theirs
242 .iter()
243 .any(|window| threshold.reached_by(window, now))
244 {
245 return None;
246 }
247 let reports = |kind: &str| {
248 theirs
249 .iter()
250 .any(|window| window.scope.is_none() && crate::usage::limit_name(&window.kind) == kind)
251 };
252 let overall = reports("session") && reports("weekly_all");
253 for had in &mine.windows {
254 if theirs.iter().any(|window| window.same_limit(had)) {
255 continue;
256 }
257 if !(overall && had.scope.is_some() && !had.same_limit(limit)) {
258 return None;
259 }
260 }
261 let used = theirs
262 .iter()
263 .find(|window| window.same_limit(limit))?
264 .used(now);
265 let fullest = theirs
266 .iter()
267 .filter(|window| !window.same_limit(limit))
268 .map(|window| window.used(now))
269 .fold(0.0, f64::max);
270 Some((used, fullest))
271}
272
273pub(crate) fn decide(
276 state: &State,
277 rows: &[Row],
278 ledger: &Ledger,
279 threshold: Threshold,
280 now: i64,
281) -> Decision {
282 let claude = |row: &&Row| row.provider == ProviderId::Claude && row.label.is_some();
283 let Some(current) = rows.iter().filter(claude).find(|row| row.signed_in) else {
284 return Decision::Stay;
285 };
286 let (Some(from), Some(reading)) = (current.key(), current.usage.as_ref()) else {
287 return Decision::Stay;
288 };
289 let Some(limit) = reached(reading, threshold, now) else {
290 return Decision::Stay;
291 };
292 let settling = state
293 .get(&from)
294 .and_then(|account| account.last_used_at)
295 .is_some_and(|at| (0..SETTLING_SECONDS).contains(&(now - at)));
296 let tried = ledger.tried(¤t.id, limit);
297 let waiting = tried.is_some_and(|tried| {
298 tried.switched || tried.attempts >= ATTEMPTS || now - tried.at < retry_after(tried.waits)
299 });
300 if settling || waiting {
301 return Decision::Stay;
302 }
303 let passed_over = tried.map_or(&[][..], |tried| tried.passed_over.as_slice());
304 let best = rows
305 .iter()
306 .filter(claude)
307 .filter(|row| row.switchable(now))
308 .filter(|row| !passed_over.contains(&row.id))
309 .filter_map(|row| Some((row, room(row, reading, limit, threshold, now)?)))
310 .min_by(|(_, (a, a_most)), (_, (b, b_most))| {
311 a.total_cmp(b).then_with(|| a_most.total_cmp(b_most))
312 });
313 match best.and_then(|(row, _)| row.key()) {
314 Some(to) => Decision::Switch(Plan {
315 from,
316 from_id: current.id.clone(),
317 to,
318 limit: limit.clone(),
319 }),
320 None => Decision::NoRoom {
321 from,
322 limit: limit.clone(),
323 },
324 }
325}
326
327#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
332pub(crate) struct Ledger {
333 #[serde(default)]
334 limits: BTreeMap<String, Tried>,
335}
336
337#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
339struct Tried {
340 resets_at: i64,
342 attempts: u32,
345 #[serde(default)]
348 waits: u32,
349 at: i64,
351 #[serde(default)]
353 switched: bool,
354 #[serde(default)]
357 passed_over: Vec<String>,
358}
359
360fn path(ctx: &Context) -> PathBuf {
361 home::dir(ctx).join("autoswitch.json")
362}
363
364fn entry(id: &str, limit: &Window) -> String {
366 [
367 id,
368 crate::usage::limit_name(&limit.kind),
369 limit.scope.as_deref().unwrap_or_default(),
370 ]
371 .join("/")
372}
373
374impl Ledger {
375 pub(crate) fn load(ctx: &Context) -> Ledger {
379 std::fs::read_to_string(path(ctx))
380 .ok()
381 .and_then(|raw| serde_json::from_str(&raw).ok())
382 .unwrap_or_default()
383 }
384
385 pub(crate) fn save(&mut self, ctx: &Context, permit: Permit) -> crate::error::Result<()> {
387 let now = ctx.now();
388 self.limits.retain(|_, tried| {
389 tried.resets_at > now || (tried.resets_at == 0 && now - tried.at < 7 * 86_400)
390 });
391 let body = serde_json::to_vec(self).map_err(|e| crate::error::Error::StateWriteFailed {
392 path: path(ctx),
393 source: std::io::Error::other(e),
394 })?;
395 atomic::write(permit, &path(ctx), &body, atomic::Perms::Secret).map_err(|source| {
396 crate::error::Error::StateWriteFailed {
397 path: path(ctx),
398 source,
399 }
400 })
401 }
402
403 fn tried(&self, id: &str, limit: &Window) -> Option<&Tried> {
405 self.limits
406 .get(&entry(id, limit))
407 .filter(|tried| same_reset(tried.resets_at, limit.resets_at.unwrap_or(0)))
408 }
409
410 fn trying(&mut self, plan: &Plan) -> &mut Tried {
411 let resets_at = plan.limit.resets_at.unwrap_or(0);
412 let tried = self
413 .limits
414 .entry(entry(&plan.from_id, &plan.limit))
415 .or_default();
416 if !same_reset(tried.resets_at, resets_at) {
417 *tried = Tried {
418 resets_at,
419 ..Tried::default()
420 };
421 }
422 tried
423 }
424
425 pub(crate) fn attempt(&mut self, plan: &Plan, now: i64) {
427 let tried = self.trying(plan);
428 tried.attempts += 1;
429 tried.at = now;
430 }
431
432 pub(crate) fn waited(&mut self, plan: &Plan) {
435 let tried = self.trying(plan);
436 tried.attempts = tried.attempts.saturating_sub(1);
437 tried.waits = tried.waits.saturating_add(1);
438 }
439
440 pub(crate) fn switched(&mut self, plan: &Plan) {
442 self.trying(plan).switched = true;
443 }
444
445 pub(crate) fn pass_over(&mut self, plan: &Plan, to_id: &str) {
448 let tried = self.trying(plan);
449 tried.waits = 0;
450 if !tried.passed_over.iter().any(|id| id == to_id) {
451 tried.passed_over.push(to_id.to_owned());
452 }
453 }
454}
455
456#[derive(Debug)]
458pub(crate) enum Next {
459 Switch(Plan),
460 Say(Auto),
461}
462
463pub(crate) fn look(ctx: &Context, state: &State, threshold: Threshold) -> Next {
467 let rows = crate::status::gather_offline(ctx, state).rows;
468 let plan = match decide(state, &rows, &Ledger::load(ctx), threshold, ctx.now()) {
469 Decision::Stay => return Next::Say(Auto::Idle),
470 Decision::NoRoom { from, limit } => {
471 return Next::Say(Auto::NoRoom {
472 from: state.typed(&from),
473 limit,
474 });
475 }
476 Decision::Switch(plan) => plan,
477 };
478 let overridden = crate::provider::of(ProviderId::Claude).overridden_by(ctx);
479 let why = if crate::switch::interrupted(ctx) {
480 Skip::SwitchInterrupted
481 } else if crate::settings::custom_oauth(ctx) {
482 Skip::CustomOauth
483 } else if !overridden.is_empty() {
484 Skip::Overridden(overridden)
485 } else {
486 return Next::Switch(plan);
487 };
488 Next::Say(Auto::Skipped {
489 from: state.typed(&plan.from),
490 limit: plan.limit,
491 why,
492 })
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::state::Park;
499 use crate::switch::harness::{NOW, account, window};
500 use crate::usage::Source;
501
502 fn scoped(kind: &str, model: &str, percent: f64) -> Window {
503 Window {
504 scope: Some(model.into()),
505 ..window(kind, percent)
506 }
507 }
508
509 fn row(label: &str, signed_in: bool, windows: Vec<Window>) -> Row {
510 Row {
511 provider: ProviderId::Claude,
512 label: Some(label.into()),
513 email: format!("{label}@example.com"),
514 id: label.into(),
515 account_uuid: label.into(),
516 organization_uuid: Some("org".into()),
517 signed_in,
518 parked: (!signed_in).then(|| Park {
519 service: format!("pitboard-park-{label}-1"),
520 parked_at: NOW,
521 refresh_fingerprint: label.into(),
522 access_expires_at: Some(NOW + 3600),
523 refresh_expires_at: Some(NOW + 30 * 86_400),
524 }),
525 usage: Some(Snapshot {
526 windows,
527 observed_at: Some(NOW),
528 account_uuid: Some(label.into()),
529 source: Source::Remembered,
530 }),
531 stale: None,
532 }
533 }
534
535 fn state_of(rows: &[Row]) -> State {
536 let mut state = State::default();
537 for row in rows {
538 state.accounts.push(account(
539 row.label.as_deref().unwrap(),
540 &row.account_uuid,
541 row.parked.clone(),
542 ));
543 }
544 state
545 }
546
547 fn decided(rows: &[Row]) -> Decision {
548 decide(
549 &state_of(rows),
550 rows,
551 &Ledger::default(),
552 Threshold::DEFAULT,
553 NOW,
554 )
555 }
556
557 fn to(decision: &Decision) -> Option<&str> {
558 match decision {
559 Decision::Switch(plan) => Some(plan.to.label.as_str()),
560 _ => None,
561 }
562 }
563
564 fn work_at(percent: f64) -> Row {
565 row(
566 "work",
567 true,
568 vec![window("session", percent), window("weekly_all", 20.0)],
569 )
570 }
571
572 fn spare() -> Row {
573 row(
574 "spare",
575 false,
576 vec![window("session", 10.0), window("weekly_all", 30.0)],
577 )
578 }
579
580 #[test]
581 fn a_limit_below_the_share_is_left_alone_and_one_at_it_switches() {
582 assert_eq!(decided(&[work_at(94.9), spare()]), Decision::Stay);
583 let decision = decided(&[work_at(95.0), spare()]);
584 assert_eq!(to(&decision), Some("spare"));
585 let Decision::Switch(plan) = decision else {
586 unreachable!()
587 };
588 assert_eq!(
589 (plan.from.label.as_str(), plan.from_id.as_str()),
590 ("work", "work")
591 );
592 assert_eq!(plan.limit.kind, "session");
593 }
594
595 #[test]
596 fn every_limit_the_account_reports_can_reach_the_share() {
597 let weekly = row(
598 "work",
599 true,
600 vec![window("session", 10.0), window("weekly_all", 97.0)],
601 );
602 let opus = row(
603 "work",
604 true,
605 vec![
606 window("session", 10.0),
607 window("weekly_all", 10.0),
608 scoped("weekly_scoped", "Opus", 96.0),
609 ],
610 );
611 let named_as_a_session_names_them = row(
612 "work",
613 true,
614 vec![window("five_hour", 99.0), window("seven_day", 10.0)],
615 );
616 for (case, work) in [
617 ("weekly", weekly),
618 ("opus", opus),
619 ("five_hour", named_as_a_session_names_them),
620 ] {
621 let spare = row(
622 "spare",
623 false,
624 vec![
625 window("session", 0.0),
626 window("weekly_all", 0.0),
627 scoped("weekly_scoped", "Opus", 0.0),
628 ],
629 );
630 assert_eq!(to(&decided(&[work, spare])), Some("spare"), "{case}");
631 }
632 }
633
634 #[test]
635 fn the_limit_furthest_past_the_share_is_the_one_acted_on() {
636 let work = row(
637 "work",
638 true,
639 vec![window("session", 96.0), window("weekly_all", 98.0)],
640 );
641 let Decision::Switch(plan) = decided(&[work, spare()]) else {
642 panic!("a switch")
643 };
644 assert_eq!(plan.limit.kind, "weekly_all");
645 }
646
647 #[test]
648 fn a_limit_whose_reset_has_passed_counts_as_reset() {
649 let mut work = work_at(100.0);
650 work.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW);
651 assert_eq!(decided(&[work, spare()]), Decision::Stay);
652 }
653
654 #[test]
655 fn an_account_in_use_with_no_reading_is_never_switched_from() {
656 let mut work = work_at(99.0);
657 work.usage = None;
658 assert_eq!(decided(&[work, spare()]), Decision::Stay);
659 }
660
661 #[test]
663 fn codex_is_never_switched() {
664 let mut work = work_at(99.0);
665 let mut other = spare();
666 work.provider = ProviderId::Codex;
667 other.provider = ProviderId::Codex;
668 assert_eq!(decided(&[work, other]), Decision::Stay);
669 assert_eq!(
670 decided(&[work_at(99.0), {
671 let mut codex = spare();
672 codex.provider = ProviderId::Codex;
673 codex
674 }]),
675 Decision::NoRoom {
676 from: Key::new(ProviderId::Claude, "work"),
677 limit: window("session", 99.0)
678 }
679 );
680 }
681
682 #[test]
683 fn an_account_with_any_limit_at_the_share_is_no_place_to_go() {
684 let full_weekly = || {
685 row(
686 "spare",
687 false,
688 vec![window("session", 0.0), window("weekly_all", 95.0)],
689 )
690 };
691 assert!(matches!(
692 decided(&[work_at(96.0), full_weekly()]),
693 Decision::NoRoom { .. }
694 ));
695 let roomy = row(
696 "roomy",
697 false,
698 vec![window("session", 60.0), window("weekly_all", 10.0)],
699 );
700 assert_eq!(
701 to(&decided(&[work_at(96.0), full_weekly(), roomy])),
702 Some("roomy")
703 );
704 }
705
706 #[test]
707 fn an_account_that_cannot_be_switched_to_is_passed_over() {
708 let mut expired = spare();
709 expired.parked.as_mut().unwrap().refresh_expires_at = Some(NOW);
710 assert!(matches!(
711 decided(&[work_at(96.0), expired]),
712 Decision::NoRoom { .. }
713 ));
714 }
715
716 #[test]
717 fn a_limit_the_other_account_does_not_report_counts_as_spent() {
718 let unknown_weekly = row("spare", false, vec![window("session", 0.0)]);
719 assert!(matches!(
720 decided(&[work_at(96.0), unknown_weekly]),
721 Decision::NoRoom { .. }
722 ));
723 let mut unread = spare();
724 unread.usage = None;
725 assert!(matches!(
726 decided(&[work_at(96.0), unread]),
727 Decision::NoRoom { .. }
728 ));
729 }
730
731 #[test]
735 fn a_limit_for_a_model_the_other_plan_does_not_have_is_not_counted_against_it() {
736 let work = row(
737 "work",
738 true,
739 vec![
740 window("session", 97.0),
741 window("weekly_all", 20.0),
742 scoped("weekly_scoped", "Opus", 40.0),
743 ],
744 );
745 assert_eq!(to(&decided(&[work, spare()])), Some("spare"));
746
747 let opus_ran_out = row(
748 "work",
749 true,
750 vec![
751 window("session", 10.0),
752 window("weekly_all", 20.0),
753 scoped("weekly_scoped", "Opus", 97.0),
754 ],
755 );
756 assert!(matches!(
757 decided(&[opus_ran_out, spare()]),
758 Decision::NoRoom { .. }
759 ));
760
761 let session_only = row("spare", false, vec![window("session", 10.0)]);
762 let work = row(
763 "work",
764 true,
765 vec![
766 window("session", 97.0),
767 scoped("weekly_scoped", "Opus", 40.0),
768 ],
769 );
770 assert!(matches!(
771 decided(&[work, session_only]),
772 Decision::NoRoom { .. }
773 ));
774 }
775
776 #[test]
777 fn the_account_with_most_room_in_that_limit_is_chosen_then_the_least_full() {
778 let less = || {
779 row(
780 "less",
781 false,
782 vec![window("session", 50.0), window("weekly_all", 10.0)],
783 )
784 };
785 let more = row(
786 "more",
787 false,
788 vec![window("session", 20.0), window("weekly_all", 80.0)],
789 );
790 assert_eq!(to(&decided(&[work_at(96.0), less(), more])), Some("more"));
791 let tie = row(
792 "tie",
793 false,
794 vec![window("session", 50.0), window("weekly_all", 5.0)],
795 );
796 assert_eq!(to(&decided(&[work_at(96.0), less(), tie])), Some("tie"));
797 let same = row(
798 "same",
799 false,
800 vec![window("session", 50.0), window("weekly_all", 10.0)],
801 );
802 assert_eq!(
803 to(&decided(&[work_at(96.0), less(), same])),
804 Some("less"),
805 "the first of two alike"
806 );
807 }
808
809 #[test]
810 fn an_account_just_put_in_use_is_left_to_settle() {
811 let rows = [work_at(96.0), spare()];
812 let mut state = state_of(&rows);
813 let work = Key::new(ProviderId::Claude, "work");
814 state.used(&work, NOW - SETTLING_SECONDS + 1);
815 let ledger = Ledger::default();
816 assert_eq!(
817 decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
818 Decision::Stay
819 );
820 state.used(&work, NOW - SETTLING_SECONDS);
821 assert_eq!(
822 to(&decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW)),
823 Some("spare")
824 );
825 }
826
827 fn plan(rows: &[Row]) -> Plan {
828 match decided(rows) {
829 Decision::Switch(plan) => plan,
830 other => panic!("a switch, not {other:?}"),
831 }
832 }
833
834 #[test]
835 fn a_limit_switched_away_from_is_not_switched_away_from_again_before_it_resets() {
836 let rows = [work_at(96.0), spare()];
837 let state = state_of(&rows);
838 let mut ledger = Ledger::default();
839 let plan = plan(&rows);
840 ledger.attempt(&plan, NOW - 3600);
841 ledger.switched(&plan);
842 assert_eq!(
843 decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
844 Decision::Stay
845 );
846
847 let mut next_window = work_at(96.0);
848 next_window.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW + 3 * 3600);
849 let rows = [next_window, spare()];
850 assert_eq!(
851 to(&decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW)),
852 Some("spare")
853 );
854 }
855
856 #[test]
857 fn a_failed_attempt_is_tried_again_after_a_minute_and_at_most_three_times() {
858 let rows = [work_at(96.0), spare()];
859 let state = state_of(&rows);
860 let mut ledger = Ledger::default();
861 let plan = plan(&rows);
862 ledger.attempt(&plan, NOW);
863 let at = |now| decide(&state, &rows, &ledger, Threshold::DEFAULT, now);
864 assert_eq!(at(NOW + RETRY_SECONDS - 1), Decision::Stay);
865 assert_eq!(to(&at(NOW + RETRY_SECONDS)), Some("spare"));
866 assert_eq!(at(NOW - 1), Decision::Stay, "a clock gone backwards waits");
867
868 ledger.attempt(&plan, NOW);
869 ledger.attempt(&plan, NOW);
870 let at = |now| decide(&state, &rows, &ledger, Threshold::DEFAULT, now);
871 assert_eq!(at(NOW + 3600), Decision::Stay);
872 }
873
874 #[test]
879 fn a_failure_trying_again_may_mend_waits_longer_each_time_and_is_not_counted() {
880 let rows = [work_at(96.0), spare()];
881 let state = state_of(&rows);
882 let mut ledger = Ledger::default();
883 let plan = plan(&rows);
884 let mut at = NOW;
885 for wait in [60, 120, 240, 480, 900, 900] {
886 ledger.attempt(&plan, at);
887 ledger.waited(&plan);
888 let decided = |now| decide(&state, &rows, &ledger, Threshold::DEFAULT, now);
889 assert_eq!(decided(at + wait - 1), Decision::Stay, "within {wait} s");
890 assert_eq!(to(&decided(at + wait)), Some("spare"), "after {wait} s");
891 at += wait;
892 }
893 }
894
895 #[test]
896 fn an_account_a_switch_was_refused_over_is_passed_over_for_the_next() {
897 let other = row(
898 "other",
899 false,
900 vec![window("session", 30.0), window("weekly_all", 30.0)],
901 );
902 let rows = [work_at(96.0), spare(), other];
903 let state = state_of(&rows);
904 let mut ledger = Ledger::default();
905 let plan = plan(&rows);
906 assert_eq!(plan.to.label, "spare");
907 ledger.attempt(&plan, NOW);
908 ledger.pass_over(&plan, "spare");
909 assert_eq!(
910 to(&decide(
911 &state,
912 &rows,
913 &ledger,
914 Threshold::DEFAULT,
915 NOW + RETRY_SECONDS
916 )),
917 Some("other")
918 );
919 }
920
921 #[test]
922 fn a_reset_a_second_off_is_the_same_window_and_a_minute_off_is_not() {
923 let rows = [work_at(96.0), spare()];
924 let mut ledger = Ledger::default();
925 let mut plan = plan(&rows);
926 let resets = plan.limit.resets_at.unwrap();
927 plan.limit.resets_at = Some(resets + 59);
928 ledger.attempt(&plan, NOW - 3600);
929 ledger.switched(&plan);
930 let state = state_of(&rows);
931 assert_eq!(
932 decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
933 Decision::Stay
934 );
935 plan.limit.resets_at = Some(resets + 61);
936 let mut ledger = Ledger::default();
937 ledger.attempt(&plan, NOW - 3600);
938 ledger.switched(&plan);
939 assert_eq!(
940 to(&decide(&state, &rows, &ledger, Threshold::DEFAULT, NOW)),
941 Some("spare")
942 );
943 }
944
945 #[test]
946 fn a_share_is_between_fifty_and_ninety_nine() {
947 assert_eq!(Threshold::new(49), None);
948 assert_eq!(Threshold::new(100), None);
949 assert_eq!(Threshold::new(50).map(Threshold::percent), Some(50));
950 assert_eq!(Threshold::clamped(120).percent(), 99);
951 assert_eq!(Threshold::clamped(-3).percent(), 50);
952 assert_eq!(Threshold::default().percent(), 95);
953 let lower = Threshold::new(80).unwrap();
954 let rows = [work_at(85.0), spare()];
955 assert_eq!(decided(&rows), Decision::Stay);
956 assert_eq!(
957 to(&decide(
958 &state_of(&rows),
959 &rows,
960 &Ledger::default(),
961 lower,
962 NOW
963 )),
964 Some("spare")
965 );
966 }
967}