1use crate::context::Context;
20use crate::pace::Standing;
21use crate::provider::ProviderId;
22use crate::sessions::{Limit, Run};
23use crate::state::State;
24use crate::usage::{Snapshot, Source, Window};
25use serde_json::Value;
26use std::collections::{BTreeMap, HashMap};
27
28const FRESH_FOR: i64 = 15 * 60;
30
31#[derive(Debug, Clone, Copy, Default, PartialEq)]
33pub struct Shares {
34 pub five_hour: Option<f64>,
35 pub weekly: Option<f64>,
36}
37
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub struct Paces {
42 pub five_hour: Option<Standing>,
43 pub weekly: Option<Standing>,
44}
45
46#[derive(Debug, PartialEq)]
48pub struct Entry {
49 pub label: String,
50 pub shares: Shares,
51 pub age: Option<i64>,
53}
54
55#[derive(Debug, PartialEq)]
56pub struct StatusLine {
57 pub current: Option<String>,
59 pub session: Shares,
62 pub pace: Paces,
64 pub others: Vec<Entry>,
65}
66
67const FIVE_HOUR: &[&str] = &["session", "five_hour"];
69const WEEKLY: &[&str] = &["weekly_all", "seven_day"];
70
71fn limit<'a>(reading: &'a Snapshot, kinds: &[&str]) -> Option<&'a Window> {
73 reading
74 .windows
75 .iter()
76 .find(|w| w.scope.is_none() && kinds.contains(&w.kind.as_str()))
77}
78
79fn shares_of(reading: &Snapshot, now: i64) -> Shares {
81 let share = |kinds| limit(reading, kinds).map(|w| w.used(now));
82 Shares {
83 five_hour: share(FIVE_HOUR),
84 weekly: share(WEEKLY),
85 }
86}
87
88fn paces_of(reading: &Snapshot, now: i64) -> Paces {
91 let at = reading.observed_at.unwrap_or(now);
92 let pace = |kinds| Some(limit(reading, kinds)?.pace(at, now)?.standing);
93 Paces {
94 five_hour: pace(FIVE_HOUR),
95 weekly: pace(WEEKLY),
96 }
97}
98
99fn line(
103 state: &State,
104 signed_in: Option<&str>,
105 remembered: &HashMap<String, Snapshot>,
106 offered: Option<&Snapshot>,
107 now: i64,
108) -> StatusLine {
109 let current = signed_in.and_then(|id| state.by_id(ProviderId::Claude, id));
112 let others = state
113 .accounts
114 .iter()
115 .filter(|a| a.provider() == ProviderId::Claude)
116 .filter(|a| current.is_none_or(|c| c.id != a.id))
117 .map(|account| {
118 let reading = remembered.get(&account.id);
119 Entry {
120 label: account.label.clone(),
121 shares: reading.map_or_else(Shares::default, |r| shares_of(r, now)),
122 age: reading
123 .and_then(|r| r.observed_at)
124 .map(|at| now - at)
125 .filter(|age| *age > FRESH_FOR),
126 }
127 })
128 .collect();
129 let known = signed_in.and_then(|id| remembered.get(id));
130 let in_use = crate::usage::merge(known, offered, now);
131 StatusLine {
132 current: current.map(|a| a.label.clone()),
133 session: in_use
134 .as_ref()
135 .map_or_else(Shares::default, |r| shares_of(r, now)),
136 pace: in_use
137 .as_ref()
138 .map_or_else(Paces::default, |r| paces_of(r, now)),
139 others,
140 }
141}
142
143fn run_of(input: &Value, signed_in: Option<&str>) -> Run {
146 let limits = input.get("rate_limits");
147 Run {
148 account: signed_in.map(str::to_owned),
149 limits: ["five_hour", "seven_day"]
150 .into_iter()
151 .filter_map(|name| {
152 let w = limits?.get(name)?;
153 let limit = Limit {
154 used_percentage: w.get("used_percentage")?.as_f64()?,
155 resets_at: w.get("resets_at").and_then(Value::as_i64),
156 };
157 Some((name.to_string(), limit))
158 })
159 .collect::<BTreeMap<_, _>>(),
160 }
161}
162
163fn session_snapshot(
191 run: &Run,
192 before: Option<&Run>,
193 state: &State,
194 remembered: &HashMap<String, Snapshot>,
195 now: i64,
196) -> Option<Snapshot> {
197 let signed_in = run.account.as_deref()?;
198 let before = before.filter(|b| b.account == run.account)?;
199 let adopting = state
200 .by_id(ProviderId::Claude, signed_in)
201 .and_then(|account| account.last_used_at)
202 .is_some_and(|at| {
203 (0..i64::from(crate::switch::ADOPTION_CEILING_SECONDS)).contains(&(now - at))
204 });
205 if adopting {
206 return None;
207 }
208 let known = remembered.get(signed_in);
209 let others: Vec<&Snapshot> = state
212 .accounts
213 .iter()
214 .filter(|a| a.provider() == ProviderId::Claude)
215 .filter(|a| a.id != signed_in)
216 .filter_map(|a| remembered.get(&a.id))
217 .collect();
218 let has = |reading: &Snapshot, window: &Window| {
219 reading.windows.iter().any(|had| had.same_window(window))
220 };
221 let window = |passed: &str, named: &str| -> Option<Window> {
224 let limit = run.limits.get(passed)?;
225 if before.limits.get(passed) == Some(limit) {
226 return None;
227 }
228 let given = Window {
229 kind: named.to_string(),
230 scope: None,
231 severity: None,
232 percent: limit.used_percentage,
233 resets_at: limit.resets_at,
234 is_active: true,
235 length_seconds: crate::usage::anthropic_window_length(named),
236 };
237 let over = given.resets_at.is_some_and(|at| at <= now);
238 let anothers =
239 !known.is_some_and(|k| has(k, &given)) && others.iter().any(|r| has(r, &given));
240 if over || anothers {
241 return None;
242 }
243 Some(
244 match known.and_then(|k| k.windows.iter().find(|h| h.same_limit(&given))) {
245 Some(had) => Window {
246 percent: given.percent,
247 resets_at: given.resets_at,
248 severity: None,
249 ..had.clone()
250 },
251 None => given,
252 },
253 )
254 };
255 let windows: Vec<Window> = [("five_hour", "session"), ("seven_day", "weekly_all")]
256 .into_iter()
257 .filter_map(|(passed, named)| window(passed, named))
258 .collect();
259 (!windows.is_empty()).then_some(Snapshot {
260 windows,
261 observed_at: None,
262 account_uuid: None,
263 source: Source::Live,
264 })
265}
266
267pub fn read(ctx: &Context, permit: Option<crate::service::Permit>, input: &str) -> StatusLine {
280 let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
281 let state = crate::state::load(ctx).unwrap_or_default();
282 let signed_in = crate::provider::of(ProviderId::Claude)
285 .recorded_identity(ctx)
286 .map(|found| state.id_of(ProviderId::Claude, &crate::api::Owner::from(found)));
287 let now = ctx.now();
288 let remembered = crate::readings::load(ctx);
289 let run = run_of(&input, signed_in.as_deref());
290 let before = input
291 .get("session_id")
292 .and_then(Value::as_str)
293 .and_then(|id| match permit {
294 Some(permit) => crate::sessions::exchange(ctx, permit, id, &run),
295 None => crate::sessions::last(ctx, id),
296 });
297 let offered = session_snapshot(&run, before.as_ref(), &state, &remembered, now);
298 if let (Some(permit), Some(id), Some(offered)) =
299 (permit, signed_in.as_deref(), offered.as_ref())
300 {
301 crate::readings::remember(ctx, permit, &[(id.to_string(), offered.clone())]);
302 }
303 line(
304 &state,
305 signed_in.as_deref(),
306 &remembered,
307 offered.as_ref(),
308 now,
309 )
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::service::Permit;
316 use crate::state::Account;
317 use crate::time::{Clock, FixedClock};
318 use crate::usage::{Source, Window};
319 use serde_json::json;
320 use std::sync::Arc;
321
322 const NOW: i64 = 1_789_935_000;
323 const HOUR: i64 = 3_600;
324 const DAY: i64 = 86_400;
325
326 fn state() -> State {
327 let account = |label: &str| Account {
328 last_used_at: None,
329 label: label.into(),
330 id: format!("{label}-uuid"),
331 account_uuid: format!("{label}-uuid"),
332 email: format!("{label}@example.com"),
333 detail: crate::state::Detail::Claude {
334 organization_uuid: "o".into(),
335 oauth_account: json!({}),
336 },
337 parked: None,
338 };
339 State {
340 accounts: vec![account("work"), account("personal"), account("side")],
341 ..State::default()
342 }
343 }
344
345 fn reading(five: f64, week: f64, observed_at: i64, resets_at: i64) -> Snapshot {
346 timed((five, resets_at), (week, resets_at), observed_at)
347 }
348
349 fn timed(five_hour: (f64, i64), weekly: (f64, i64), observed_at: i64) -> Snapshot {
351 let window = |kind: &str, (percent, resets_at): (f64, i64)| Window {
352 kind: kind.into(),
353 scope: None,
354 percent,
355 resets_at: Some(resets_at),
356 is_active: false,
357 severity: None,
358 length_seconds: None,
359 };
360 Snapshot {
361 windows: vec![window("session", five_hour), window("weekly_all", weekly)],
362 observed_at: Some(observed_at),
363 account_uuid: None,
364 source: Source::Remembered,
365 }
366 }
367
368 fn shares(five_hour: f64, weekly: f64) -> Shares {
369 Shares {
370 five_hour: Some(five_hour),
371 weekly: Some(weekly),
372 }
373 }
374
375 fn session(five_hour: f64, weekly: f64, resets_at: i64) -> Value {
377 passed((five_hour, resets_at), (weekly, resets_at))
378 }
379
380 fn passed(five_hour: (f64, i64), weekly: (f64, i64)) -> Value {
383 json!({"rate_limits": {
384 "five_hour": {"used_percentage": five_hour.0, "resets_at": five_hour.1},
385 "seven_day": {"used_percentage": weekly.0, "resets_at": weekly.1}
386 }})
387 }
388
389 fn answered(input: &Value, remembered: &HashMap<String, Snapshot>) -> Option<Snapshot> {
392 let before = Run {
393 account: Some("work-uuid".into()),
394 limits: BTreeMap::new(),
395 };
396 session_snapshot(
397 &run_of(input, Some("work-uuid")),
398 Some(&before),
399 &state(),
400 remembered,
401 NOW,
402 )
403 }
404
405 struct Scratch(std::path::PathBuf);
407
408 impl Drop for Scratch {
409 fn drop(&mut self) {
410 let _ = std::fs::remove_dir_all(&self.0);
411 }
412 }
413
414 fn machine(name: &str) -> (Context, Scratch) {
416 let root = std::env::temp_dir().join(format!(
417 "pitboard-statusline-{name}-{}-{:?}",
418 std::process::id(),
419 std::thread::current().id()
420 ));
421 let _ = std::fs::remove_dir_all(&root);
422 std::fs::create_dir_all(&root).expect("a scratch home");
423 let ctx = Context::new(root.clone())
424 .with_pitboard_home(root.join(".pitboard"))
425 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
426 sign_in(&ctx, "work");
427 (ctx, Scratch(root))
428 }
429
430 fn at(ctx: &Context, now: i64) -> Context {
432 ctx.clone()
433 .with_clock(Arc::new(FixedClock::at(now)) as Arc<dyn Clock>)
434 }
435
436 fn sign_in(ctx: &Context, label: &str) {
438 std::fs::write(
439 ctx.home().join(".claude.json"),
440 json!({"oauthAccount": {
441 "accountUuid": format!("{label}-uuid"),
442 "emailAddress": format!("{label}@example.com"),
443 "organizationUuid": "o",
444 }})
445 .to_string(),
446 )
447 .expect("a Claude Code config");
448 }
449
450 fn run(ctx: &Context, id: &str, input: &Value) -> StatusLine {
452 let mut input = input.clone();
453 input["session_id"] = json!(id);
454 read(ctx, Some(Permit::for_a_test()), &input.to_string())
455 }
456
457 fn open(ctx: &Context, id: &str) {
460 run(ctx, id, &json!({}));
461 }
462
463 fn recorded(ctx: &Context) -> Snapshot {
464 crate::readings::load(ctx)
465 .remove("work-uuid")
466 .expect("a reading")
467 }
468
469 #[test]
470 fn names_the_account_in_use_and_what_every_account_has_left() {
471 let input = json!({"rate_limits": {
472 "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
473 "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 86_400}
474 }});
475 let remembered = HashMap::from([
476 (
477 "personal-uuid".to_string(),
478 reading(12.0, 40.0, NOW - 60, NOW + 2 * HOUR),
479 ),
480 (
481 "side-uuid".to_string(),
482 reading(90.0, 88.0, NOW - 3 * 3_600, NOW - 1),
483 ),
484 ]);
485 let offered = answered(&input, &remembered);
486 let line = line(
487 &state(),
488 Some("work-uuid"),
489 &remembered,
490 offered.as_ref(),
491 NOW,
492 );
493 assert_eq!(line.current.as_deref(), Some("work"));
494 assert_eq!(line.session, shares(46.4, 70.0));
495 assert_eq!(
496 line.others,
497 [
498 Entry {
499 label: "personal".into(),
500 shares: shares(12.0, 40.0),
501 age: None,
502 },
503 Entry {
504 label: "side".into(),
505 shares: shares(0.0, 0.0),
506 age: Some(3 * 3_600),
507 },
508 ],
509 "a window past its reset counts as reset, and an old reading says how old"
510 );
511 }
512
513 #[test]
517 fn the_account_in_use_says_its_pace_on_each_limit() {
518 let input = json!({"rate_limits": {
519 "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
520 "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 5 * DAY}
521 }});
522 let offered = answered(&input, &HashMap::new());
523 let shown = line(
524 &state(),
525 Some("work-uuid"),
526 &HashMap::new(),
527 offered.as_ref(),
528 NOW,
529 );
530 assert_eq!(shown.pace.five_hour, Some(Standing::Under));
531 assert!(
532 matches!(shown.pace.weekly, Some(Standing::Over { .. })),
533 "{:?}",
534 shown.pace
535 );
536 assert_eq!(
537 line(&state(), None, &HashMap::new(), None, NOW).pace,
538 Paces::default()
539 );
540 }
541
542 #[test]
543 fn what_is_unknown_is_left_unknown_not_zero() {
544 let line = line(&state(), None, &HashMap::new(), None, NOW);
545 assert_eq!(line.current, None);
546 assert_eq!(line.session, Shares::default());
547 assert!(line.others.iter().all(|e| e.shares == Shares::default()));
548 assert_eq!(line.others.len(), 3);
549 }
550
551 #[test]
555 fn another_tools_accounts_are_not_on_claude_codes_line() {
556 let codex = |label: &str, uuid: &str| Account {
557 last_used_at: None,
558 label: label.into(),
559 id: uuid.into(),
560 account_uuid: uuid.into(),
561 email: format!("{label}@example.com"),
562 detail: crate::state::Detail::Codex {
563 workspace_id: None,
564 plan: None,
565 },
566 parked: None,
567 };
568 let mut s = state();
569 s.accounts.insert(0, codex("shadow", "work-uuid"));
570 s.accounts.push(codex("work", "codex-work"));
571
572 let found = line(&s, Some("work-uuid"), &HashMap::new(), None, NOW);
573 assert_eq!(found.current.as_deref(), Some("work"));
574 let others: Vec<&str> = found.others.iter().map(|e| e.label.as_str()).collect();
575 assert_eq!(others, ["personal", "side"]);
576
577 let found = line(&s, Some("codex-work"), &HashMap::new(), None, NOW);
578 assert_eq!(
579 found.current, None,
580 "a Codex identity names no Claude Code account"
581 );
582 }
583
584 #[test]
588 fn the_account_in_use_shows_the_newer_of_the_session_and_what_is_remembered() {
589 let remembered = HashMap::from([(
590 "work-uuid".to_string(),
591 reading(22.0, 6.0, NOW - 60, NOW + 600),
592 )]);
593 let shown = |input: &Value, remembered: &HashMap<String, Snapshot>| {
594 let offered = answered(input, remembered);
595 line(
596 &state(),
597 Some("work-uuid"),
598 remembered,
599 offered.as_ref(),
600 NOW,
601 )
602 .session
603 };
604 assert_eq!(
605 shown(&session(20.0, 5.0, NOW + 600), &remembered),
606 shares(22.0, 6.0)
607 );
608 assert_eq!(
609 shown(&session(25.0, 7.0, NOW + 600), &remembered),
610 shares(25.0, 7.0)
611 );
612
613 let next = HashMap::from([(
616 "work-uuid".to_string(),
617 reading(1.0, 6.0, NOW - 60, NOW + 5 * 3_600),
618 )]);
619 assert_eq!(shown(&session(90.0, 5.0, NOW - 1), &next), shares(1.0, 6.0));
620 }
621
622 #[test]
625 fn the_account_in_use_is_the_one_in_the_organisation_the_config_names() {
626 let (ctx, _scratch) = machine("organisations");
627 let mut accounts = state();
628 accounts.upsert(Account {
629 last_used_at: None,
630 label: "team".into(),
631 id: "work-uuid_team-org".into(),
632 account_uuid: "work-uuid".into(),
633 email: "work@example.com".into(),
634 detail: crate::state::Detail::Claude {
635 organization_uuid: "team-org".into(),
636 oauth_account: json!({}),
637 },
638 parked: None,
639 });
640 crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
641 std::fs::write(
642 ctx.home().join(".claude.json"),
643 json!({"oauthAccount": {
644 "accountUuid": "work-uuid",
645 "emailAddress": "work@example.com",
646 "organizationUuid": "team-org",
647 }})
648 .to_string(),
649 )
650 .expect("a Claude Code config");
651
652 let shown = run(&ctx, "pane", &json!({}));
653 assert_eq!(shown.current.as_deref(), Some("team"));
654 let others: Vec<&str> = shown.others.iter().map(|e| e.label.as_str()).collect();
655 assert_eq!(others, ["work", "personal", "side"]);
656 }
657
658 #[test]
662 fn the_sessions_numbers_are_recorded_when_they_are_newer() {
663 let (ctx, _scratch) = machine("records");
664 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
665 crate::readings::remember(
666 &ctx,
667 Permit::for_a_test(),
668 &[("work-uuid".into(), reading(22.0, 6.0, NOW - 60, NOW + 600))],
669 );
670 run(&ctx, "busy", &session(22.0, 6.0, NOW + 600));
671 run(&ctx, "slow", &session(18.0, 4.0, NOW + 600));
672
673 let busy = run(&ctx, "busy", &session(25.0, 7.0, NOW + 600));
674 assert_eq!(busy.session, shares(25.0, 7.0));
675 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 7.0));
676
677 let slow = run(&ctx, "slow", &session(20.0, 5.0, NOW + 600));
678 assert_eq!(slow.session, shares(25.0, 7.0));
679 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 7.0));
680 }
681
682 #[test]
687 fn a_session_moves_the_numbers_and_leaves_what_only_anthropic_says() {
688 let (ctx, _scratch) = machine("leaves");
689 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
690 let mut answered = reading(22.0, 6.0, NOW - 60, NOW + 600);
691 answered.windows[0].severity = Some("normal".into());
692 answered.windows[0].is_active = true;
693 crate::readings::remember(
694 &ctx,
695 Permit::for_a_test(),
696 &[("work-uuid".into(), answered)],
697 );
698
699 open(&ctx, "pane");
700 run(&ctx, "pane", &session(25.0, 70.0, NOW + 600));
701 let windows = recorded(&ctx).windows;
702 let said: Vec<(&str, f64, bool)> = windows
703 .iter()
704 .map(|w| (w.kind.as_str(), w.percent, w.is_active))
705 .collect();
706 assert_eq!(
707 said,
708 [("session", 25.0, true), ("weekly_all", 70.0, false)],
709 "under the names it had, and working against the limit it was"
710 );
711 assert_eq!(
712 windows[0].severity, None,
713 "a share Anthropic has not graded"
714 );
715 }
716
717 #[test]
722 fn a_session_holding_the_account_before_a_switch_is_not_recorded_as_the_one_after() {
723 let (ctx, _scratch) = machine("switched");
724 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
725 let work = |five: f64, weekly: f64, observed_at: i64| {
726 timed((five, NOW + 2 * HOUR), (weekly, NOW + 3 * DAY), observed_at)
727 };
728 crate::readings::remember(
729 &ctx,
730 Permit::for_a_test(),
731 &[("work-uuid".into(), work(10.0, 30.0, NOW - 60))],
732 );
733 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
734 sign_in(&ctx, "personal");
735 open(&ctx, "pane");
736 run(&ctx, "pane", &personals);
737
738 sign_in(&ctx, "work");
739 for _ in 0..2 {
740 let idle = run(&ctx, "pane", &personals);
741 assert_eq!(idle.current.as_deref(), Some("work"));
742 assert_eq!(
743 idle.session,
744 shares(10.0, 30.0),
745 "the pane shows work's own"
746 );
747 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
748 }
749
750 let mut answered = work(12.0, 31.0, NOW);
751 answered.source = Source::Live;
752 crate::readings::remember(
753 &ctx,
754 Permit::for_a_test(),
755 &[("work-uuid".into(), answered)],
756 );
757 assert_eq!(
758 shares_of(&recorded(&ctx), NOW),
759 shares(12.0, 31.0),
760 "and what Anthropic says about work is taken"
761 );
762 }
763
764 #[test]
768 fn a_session_holding_a_forgotten_accounts_numbers_is_not_recorded_as_the_one_in_use() {
769 let (ctx, _scratch) = machine("forgotten");
770 let mut accounts = state();
771 crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
772 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
773 sign_in(&ctx, "personal");
774 open(&ctx, "pane");
775 run(&ctx, "pane", &personals);
776 let works = timed(
777 (10.0, NOW + 2 * HOUR),
778 (30.0, NOW + 3 * DAY),
779 NOW - 2 * HOUR,
780 );
781 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
782
783 accounts.accounts[0].last_used_at = Some(NOW - HOUR);
784 crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
785 sign_in(&ctx, "work");
786 run(&ctx, "pane", &personals);
787 accounts.accounts.retain(|a| a.label != "personal");
788 crate::state::save(&ctx, Permit::for_a_test(), &accounts).expect("an account index");
789 crate::readings::forget(&ctx, Permit::for_a_test(), "personal-uuid");
790
791 let idle = run(&ctx, "pane", &personals);
792 assert_eq!(
793 idle.session,
794 shares(10.0, 30.0),
795 "the pane shows work's own"
796 );
797 let mut answered = timed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY), NOW);
798 answered.source = Source::Live;
799 crate::readings::remember(
800 &ctx,
801 Permit::for_a_test(),
802 &[("work-uuid".into(), answered)],
803 );
804 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(12.0, 31.0));
805 }
806
807 #[test]
811 fn a_session_holding_an_unenrolled_accounts_numbers_is_not_recorded_after_a_login() {
812 let (ctx, _scratch) = machine("unenrolled");
813 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
814 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
815 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
816 let guests = passed((97.0, NOW + 4 * HOUR), (80.0, NOW + 6 * DAY));
817 sign_in(&ctx, "guest");
818 open(&ctx, "pane");
819 run(&ctx, "pane", &guests);
820
821 sign_in(&ctx, "work");
822 for _ in 0..2 {
823 let idle = run(&ctx, "pane", &guests);
824 assert_eq!(
825 idle.session,
826 shares(10.0, 30.0),
827 "the pane shows work's own"
828 );
829 }
830 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
831 }
832
833 #[test]
837 fn a_session_seen_for_the_first_time_offers_nothing_until_its_next_response() {
838 let (ctx, _scratch) = machine("first");
839 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
840 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
841 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
842
843 let first = run(
844 &ctx,
845 "pane",
846 &passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY)),
847 );
848 assert_eq!(
849 first.session,
850 shares(10.0, 30.0),
851 "the pane shows work's own"
852 );
853 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
854
855 let next = run(
856 &ctx,
857 "pane",
858 &passed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
859 );
860 assert_eq!(next.session, shares(12.0, 31.0));
861 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(12.0, 31.0));
862 }
863
864 #[test]
869 fn what_a_session_passes_as_the_account_named_changes_is_not_recorded() {
870 let (ctx, _scratch) = machine("changed");
871 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
872 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
873 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
874 sign_in(&ctx, "personal");
875 open(&ctx, "pane");
876 run(
877 &ctx,
878 "pane",
879 &passed((100.0, NOW - HOUR), (50.0, NOW + 5 * DAY)),
880 );
881
882 sign_in(&ctx, "work");
883 let changed = run(
884 &ctx,
885 "pane",
886 &passed((2.0, NOW + 4 * HOUR), (51.0, NOW + 5 * DAY)),
887 );
888 assert_eq!(
889 changed.session,
890 shares(10.0, 30.0),
891 "the pane shows work's own"
892 );
893 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
894 }
895
896 #[test]
900 fn nothing_is_recorded_while_sessions_are_still_taking_up_a_switch() {
901 let (ctx, _scratch) = machine("adopting");
902 let mut switched = state();
903 switched.accounts[0].last_used_at = Some(NOW - 10);
904 crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
905 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
906 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
907 open(&ctx, "pane");
908
909 let unread = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
910 let shown = run(&ctx, "pane", &unread);
911 assert_eq!(
912 shown.session,
913 shares(10.0, 30.0),
914 "the pane shows work's own"
915 );
916 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
917
918 switched.accounts[0].last_used_at =
919 Some(NOW - i64::from(crate::switch::ADOPTION_CEILING_SECONDS));
920 crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
921 run(
922 &ctx,
923 "pane",
924 &passed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
925 );
926 assert_eq!(
927 shares_of(&recorded(&ctx), NOW),
928 shares(12.0, 31.0),
929 "and once they have, what they say is"
930 );
931 }
932
933 #[test]
937 fn nothing_is_recorded_in_the_second_the_account_is_put_to_use() {
938 let (ctx, _scratch) = machine("same-second");
939 let mut switched = state();
940 crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
941 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
942 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
943 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
944 sign_in(&ctx, "personal");
945 run(&at(&ctx, NOW - 60), "pane", &personals);
946
947 switched.accounts[0].last_used_at = Some(NOW);
948 crate::state::save(&ctx, Permit::for_a_test(), &switched).expect("an account index");
949 sign_in(&ctx, "work");
950 run(&ctx, "pane", &personals);
951 run(
952 &ctx,
953 "pane",
954 &passed((2.0, NOW + 5 * HOUR), (61.0, NOW + 5 * DAY)),
955 );
956 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
957 }
958
959 #[test]
964 fn a_response_from_the_account_before_a_login_is_known_by_its_windows() {
965 let (ctx, _scratch) = machine("login");
966 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
967 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
968 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
969 sign_in(&ctx, "personal");
970 open(&ctx, "pane");
971 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
972 run(&ctx, "pane", &personals);
973
974 sign_in(&ctx, "work");
975 run(&ctx, "pane", &personals);
976 run(
977 &ctx,
978 "pane",
979 &passed((96.0, NOW + 4 * HOUR), (61.0, NOW + 5 * DAY)),
980 );
981 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
982 }
983
984 #[test]
988 fn a_window_past_its_reset_is_not_recorded() {
989 let (ctx, _scratch) = machine("passed");
990 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
991 open(&ctx, "pane");
992 run(
993 &ctx,
994 "pane",
995 &passed((90.0, NOW - 60), (40.0, NOW + 3 * DAY)),
996 );
997 let kinds: Vec<String> = recorded(&ctx).windows.into_iter().map(|w| w.kind).collect();
998 assert_eq!(kinds, ["weekly_all"]);
999 }
1000
1001 #[test]
1008 fn a_window_that_resets_stays_in_the_reading_with_nothing_used() {
1009 let (ctx, _scratch) = machine("reset");
1010 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
1011 let works = timed((40.0, NOW - 10), (30.0, NOW + 3 * DAY), NOW - HOUR);
1012 crate::readings::remember(&ctx, Permit::for_a_test(), &[("work-uuid".into(), works)]);
1013 let floor = crate::budget::floor_for(Some(&recorded(&ctx)));
1014 run(
1015 &at(&ctx, NOW - 60),
1016 "pane",
1017 &passed((40.0, NOW - 10), (30.0, NOW + 3 * DAY)),
1018 );
1019
1020 let shown = run(
1021 &ctx,
1022 "pane",
1023 &passed((41.0, NOW - 10), (31.0, NOW + 3 * DAY)),
1024 );
1025 assert_eq!(shown.session, shares(0.0, 31.0));
1026 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(0.0, 31.0));
1027 let offline = crate::status::gather_offline(&ctx, &state());
1028 let row = offline
1029 .rows
1030 .iter()
1031 .find(|r| r.account_uuid == "work-uuid")
1032 .and_then(|r| r.usage.as_ref())
1033 .expect("work's numbers");
1034 let kinds: Vec<&str> = row.windows.iter().map(|w| w.kind.as_str()).collect();
1035 assert_eq!(kinds, ["session", "weekly_all"], "the row is kept");
1036 assert_eq!(crate::budget::floor_for(Some(&recorded(&ctx))), floor);
1037 }
1038
1039 #[test]
1043 fn a_session_still_records_its_own_accounts_next_window() {
1044 let (ctx, _scratch) = machine("next");
1045 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
1046 crate::readings::remember(
1047 &ctx,
1048 Permit::for_a_test(),
1049 &[
1050 (
1051 "work-uuid".into(),
1052 timed((100.0, NOW - 60), (40.0, NOW + 3 * DAY), NOW - HOUR),
1053 ),
1054 (
1055 "personal-uuid".into(),
1056 timed((20.0, NOW + 4 * HOUR), (50.0, NOW + 5 * DAY), NOW - 60),
1057 ),
1058 ],
1059 );
1060 run(
1061 &ctx,
1062 "pane",
1063 &passed((100.0, NOW - 60), (40.0, NOW + 3 * DAY)),
1064 );
1065 let shown = run(
1066 &ctx,
1067 "pane",
1068 &passed((2.0, NOW + 5 * HOUR), (41.0, NOW + 3 * DAY)),
1069 );
1070 assert_eq!(shown.session, shares(2.0, 41.0));
1071 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(2.0, 41.0));
1072 }
1073
1074 #[test]
1077 fn a_window_both_accounts_have_is_still_offered() {
1078 let (ctx, _scratch) = machine("coincident");
1079 crate::state::save(&ctx, Permit::for_a_test(), &state()).expect("an account index");
1080 crate::readings::remember(
1081 &ctx,
1082 Permit::for_a_test(),
1083 &[
1084 (
1085 "work-uuid".into(),
1086 timed((20.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60),
1087 ),
1088 (
1089 "personal-uuid".into(),
1090 timed((50.0, NOW + 2 * HOUR + 30), (60.0, NOW + 5 * DAY), NOW - 60),
1091 ),
1092 ],
1093 );
1094 run(
1095 &ctx,
1096 "pane",
1097 &passed((20.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY)),
1098 );
1099 let shown = run(
1100 &ctx,
1101 "pane",
1102 &passed((25.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
1103 );
1104 assert_eq!(shown.session, shares(25.0, 31.0));
1105 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 31.0));
1106 }
1107}