1use crate::context::Context;
20use crate::provider::ProviderId;
21use crate::sessions::{Limit, Run};
22use crate::state::State;
23use crate::usage::{Snapshot, Source, Window};
24use serde_json::Value;
25use std::collections::{BTreeMap, HashMap};
26
27const FRESH_FOR: i64 = 15 * 60;
29
30#[derive(Debug, Clone, Copy, Default, PartialEq)]
32pub struct Shares {
33 pub five_hour: Option<f64>,
34 pub weekly: Option<f64>,
35}
36
37#[derive(Debug, PartialEq)]
39pub struct Entry {
40 pub label: String,
41 pub shares: Shares,
42 pub age: Option<i64>,
44}
45
46#[derive(Debug, PartialEq)]
47pub struct StatusLine {
48 pub current: Option<String>,
50 pub session: Shares,
53 pub others: Vec<Entry>,
54}
55
56fn shares_of(reading: &Snapshot, now: i64) -> Shares {
58 let share = |kinds: &[&str]| {
59 reading
60 .windows
61 .iter()
62 .find(|w| w.scope.is_none() && kinds.contains(&w.kind.as_str()))
63 .map(|w| w.used(now))
64 };
65 Shares {
66 five_hour: share(&["session", "five_hour"]),
67 weekly: share(&["weekly_all", "seven_day"]),
68 }
69}
70
71fn line(
75 state: &State,
76 signed_in: Option<&str>,
77 remembered: &HashMap<String, Snapshot>,
78 offered: Option<&Snapshot>,
79 now: i64,
80) -> StatusLine {
81 let current = signed_in.and_then(|uuid| state.by_uuid(ProviderId::Claude, uuid));
84 let others = state
85 .accounts
86 .iter()
87 .filter(|a| a.provider() == ProviderId::Claude)
88 .filter(|a| current.is_none_or(|c| c.account_uuid != a.account_uuid))
89 .map(|account| {
90 let reading = remembered.get(&account.account_uuid);
91 Entry {
92 label: account.label.clone(),
93 shares: reading.map_or_else(Shares::default, |r| shares_of(r, now)),
94 age: reading
95 .and_then(|r| r.observed_at)
96 .map(|at| now - at)
97 .filter(|age| *age > FRESH_FOR),
98 }
99 })
100 .collect();
101 let known = signed_in.and_then(|uuid| remembered.get(uuid));
102 let in_use = crate::usage::merge(known, offered, now);
103 StatusLine {
104 current: current.map(|a| a.label.clone()),
105 session: in_use.map_or_else(Shares::default, |r| shares_of(&r, now)),
106 others,
107 }
108}
109
110fn run_of(input: &Value, signed_in: Option<&str>) -> Run {
113 let limits = input.get("rate_limits");
114 Run {
115 account: signed_in.map(str::to_owned),
116 limits: ["five_hour", "seven_day"]
117 .into_iter()
118 .filter_map(|name| {
119 let w = limits?.get(name)?;
120 let limit = Limit {
121 used_percentage: w.get("used_percentage")?.as_f64()?,
122 resets_at: w.get("resets_at").and_then(Value::as_i64),
123 };
124 Some((name.to_string(), limit))
125 })
126 .collect::<BTreeMap<_, _>>(),
127 }
128}
129
130fn session_snapshot(
158 run: &Run,
159 before: Option<&Run>,
160 state: &State,
161 remembered: &HashMap<String, Snapshot>,
162 now: i64,
163) -> Option<Snapshot> {
164 let signed_in = run.account.as_deref()?;
165 let before = before.filter(|b| b.account == run.account)?;
166 let adopting = state
167 .by_uuid(ProviderId::Claude, signed_in)
168 .and_then(|account| account.last_used_at)
169 .is_some_and(|at| {
170 (0..i64::from(crate::switch::ADOPTION_CEILING_SECONDS)).contains(&(now - at))
171 });
172 if adopting {
173 return None;
174 }
175 let known = remembered.get(signed_in);
176 let others: Vec<&Snapshot> = state
179 .accounts
180 .iter()
181 .filter(|a| a.provider() == ProviderId::Claude)
182 .filter(|a| a.account_uuid != signed_in)
183 .filter_map(|a| remembered.get(&a.account_uuid))
184 .collect();
185 let has = |reading: &Snapshot, window: &Window| {
186 reading.windows.iter().any(|had| had.same_window(window))
187 };
188 let window = |passed: &str, named: &str| -> Option<Window> {
191 let limit = run.limits.get(passed)?;
192 if before.limits.get(passed) == Some(limit) {
193 return None;
194 }
195 let given = Window {
196 kind: named.to_string(),
197 scope: None,
198 severity: None,
199 percent: limit.used_percentage,
200 resets_at: limit.resets_at,
201 is_active: true,
202 length_seconds: crate::usage::anthropic_window_length(named),
203 };
204 let over = given.resets_at.is_some_and(|at| at <= now);
205 let anothers =
206 !known.is_some_and(|k| has(k, &given)) && others.iter().any(|r| has(r, &given));
207 if over || anothers {
208 return None;
209 }
210 Some(
211 match known.and_then(|k| k.windows.iter().find(|h| h.same_limit(&given))) {
212 Some(had) => Window {
213 percent: given.percent,
214 resets_at: given.resets_at,
215 severity: None,
216 ..had.clone()
217 },
218 None => given,
219 },
220 )
221 };
222 let windows: Vec<Window> = [("five_hour", "session"), ("seven_day", "weekly_all")]
223 .into_iter()
224 .filter_map(|(passed, named)| window(passed, named))
225 .collect();
226 (!windows.is_empty()).then_some(Snapshot {
227 windows,
228 observed_at: None,
229 account_uuid: None,
230 source: Source::Live,
231 })
232}
233
234pub fn read(ctx: &Context, input: &str) -> StatusLine {
243 let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
244 let state = crate::state::load(ctx).unwrap_or_default();
245 let signed_in = crate::provider::of(ProviderId::Claude)
248 .recorded_identity(ctx)
249 .map(|id| id.account_id);
250 let now = ctx.now();
251 let remembered = crate::readings::load(ctx);
252 let run = run_of(&input, signed_in.as_deref());
253 let before = input
254 .get("session_id")
255 .and_then(Value::as_str)
256 .and_then(|id| crate::sessions::exchange(ctx, id, &run));
257 let offered = session_snapshot(&run, before.as_ref(), &state, &remembered, now);
258 if let (Some(uuid), Some(offered)) = (signed_in.as_deref(), offered.as_ref()) {
259 crate::readings::remember(ctx, &[(uuid.to_string(), offered.clone())]);
260 }
261 line(
262 &state,
263 signed_in.as_deref(),
264 &remembered,
265 offered.as_ref(),
266 now,
267 )
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::state::Account;
274 use crate::time::{Clock, FixedClock};
275 use crate::usage::{Source, Window};
276 use serde_json::json;
277 use std::sync::Arc;
278
279 const NOW: i64 = 1_789_935_000;
280 const HOUR: i64 = 3_600;
281 const DAY: i64 = 86_400;
282
283 fn state() -> State {
284 let account = |label: &str| Account {
285 last_used_at: None,
286 label: label.into(),
287 account_uuid: format!("{label}-uuid"),
288 email: format!("{label}@example.com"),
289 detail: crate::state::Detail::Claude {
290 organization_uuid: "o".into(),
291 oauth_account: json!({}),
292 },
293 parked: None,
294 };
295 State {
296 accounts: vec![account("work"), account("personal"), account("side")],
297 ..State::default()
298 }
299 }
300
301 fn reading(five: f64, week: f64, observed_at: i64, resets_at: i64) -> Snapshot {
302 timed((five, resets_at), (week, resets_at), observed_at)
303 }
304
305 fn timed(five_hour: (f64, i64), weekly: (f64, i64), observed_at: i64) -> Snapshot {
307 let window = |kind: &str, (percent, resets_at): (f64, i64)| Window {
308 kind: kind.into(),
309 scope: None,
310 percent,
311 resets_at: Some(resets_at),
312 is_active: false,
313 severity: None,
314 length_seconds: None,
315 };
316 Snapshot {
317 windows: vec![window("session", five_hour), window("weekly_all", weekly)],
318 observed_at: Some(observed_at),
319 account_uuid: None,
320 source: Source::Remembered,
321 }
322 }
323
324 fn shares(five_hour: f64, weekly: f64) -> Shares {
325 Shares {
326 five_hour: Some(five_hour),
327 weekly: Some(weekly),
328 }
329 }
330
331 fn session(five_hour: f64, weekly: f64, resets_at: i64) -> Value {
333 passed((five_hour, resets_at), (weekly, resets_at))
334 }
335
336 fn passed(five_hour: (f64, i64), weekly: (f64, i64)) -> Value {
339 json!({"rate_limits": {
340 "five_hour": {"used_percentage": five_hour.0, "resets_at": five_hour.1},
341 "seven_day": {"used_percentage": weekly.0, "resets_at": weekly.1}
342 }})
343 }
344
345 fn answered(input: &Value, remembered: &HashMap<String, Snapshot>) -> Option<Snapshot> {
348 let before = Run {
349 account: Some("work-uuid".into()),
350 limits: BTreeMap::new(),
351 };
352 session_snapshot(
353 &run_of(input, Some("work-uuid")),
354 Some(&before),
355 &state(),
356 remembered,
357 NOW,
358 )
359 }
360
361 struct Scratch(std::path::PathBuf);
363
364 impl Drop for Scratch {
365 fn drop(&mut self) {
366 let _ = std::fs::remove_dir_all(&self.0);
367 }
368 }
369
370 fn machine(name: &str) -> (Context, Scratch) {
372 let root = std::env::temp_dir().join(format!(
373 "pitboard-statusline-{name}-{}-{:?}",
374 std::process::id(),
375 std::thread::current().id()
376 ));
377 let _ = std::fs::remove_dir_all(&root);
378 std::fs::create_dir_all(&root).expect("a scratch home");
379 let ctx = Context::new(root.clone())
380 .with_pitboard_home(root.join(".pitboard"))
381 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
382 sign_in(&ctx, "work");
383 (ctx, Scratch(root))
384 }
385
386 fn at(ctx: &Context, now: i64) -> Context {
388 ctx.clone()
389 .with_clock(Arc::new(FixedClock::at(now)) as Arc<dyn Clock>)
390 }
391
392 fn sign_in(ctx: &Context, label: &str) {
394 std::fs::write(
395 ctx.home().join(".claude.json"),
396 json!({"oauthAccount": {
397 "accountUuid": format!("{label}-uuid"),
398 "emailAddress": format!("{label}@example.com"),
399 "organizationUuid": "o",
400 }})
401 .to_string(),
402 )
403 .expect("a Claude Code config");
404 }
405
406 fn run(ctx: &Context, id: &str, input: &Value) -> StatusLine {
408 let mut input = input.clone();
409 input["session_id"] = json!(id);
410 read(ctx, &input.to_string())
411 }
412
413 fn open(ctx: &Context, id: &str) {
416 run(ctx, id, &json!({}));
417 }
418
419 fn recorded(ctx: &Context) -> Snapshot {
420 crate::readings::load(ctx)
421 .remove("work-uuid")
422 .expect("a reading")
423 }
424
425 #[test]
426 fn names_the_account_in_use_and_what_every_account_has_left() {
427 let input = json!({"rate_limits": {
428 "five_hour": {"used_percentage": 46.4, "resets_at": NOW + 600},
429 "seven_day": {"used_percentage": 70.0, "resets_at": NOW + 86_400}
430 }});
431 let remembered = HashMap::from([
432 (
433 "personal-uuid".to_string(),
434 reading(12.0, 40.0, NOW - 60, NOW + 2 * HOUR),
435 ),
436 (
437 "side-uuid".to_string(),
438 reading(90.0, 88.0, NOW - 3 * 3_600, NOW - 1),
439 ),
440 ]);
441 let offered = answered(&input, &remembered);
442 let line = line(
443 &state(),
444 Some("work-uuid"),
445 &remembered,
446 offered.as_ref(),
447 NOW,
448 );
449 assert_eq!(line.current.as_deref(), Some("work"));
450 assert_eq!(line.session, shares(46.4, 70.0));
451 assert_eq!(
452 line.others,
453 [
454 Entry {
455 label: "personal".into(),
456 shares: shares(12.0, 40.0),
457 age: None,
458 },
459 Entry {
460 label: "side".into(),
461 shares: shares(0.0, 0.0),
462 age: Some(3 * 3_600),
463 },
464 ],
465 "a window past its reset counts as reset, and an old reading says how old"
466 );
467 }
468
469 #[test]
470 fn what_is_unknown_is_left_unknown_not_zero() {
471 let line = line(&state(), None, &HashMap::new(), None, NOW);
472 assert_eq!(line.current, None);
473 assert_eq!(line.session, Shares::default());
474 assert!(line.others.iter().all(|e| e.shares == Shares::default()));
475 assert_eq!(line.others.len(), 3);
476 }
477
478 #[test]
482 fn another_tools_accounts_are_not_on_claude_codes_line() {
483 let codex = |label: &str, uuid: &str| Account {
484 last_used_at: None,
485 label: label.into(),
486 account_uuid: uuid.into(),
487 email: format!("{label}@example.com"),
488 detail: crate::state::Detail::Codex {
489 workspace_id: None,
490 plan: None,
491 },
492 parked: None,
493 };
494 let mut s = state();
495 s.accounts.insert(0, codex("shadow", "work-uuid"));
496 s.accounts.push(codex("work", "codex-work"));
497
498 let found = line(&s, Some("work-uuid"), &HashMap::new(), None, NOW);
499 assert_eq!(found.current.as_deref(), Some("work"));
500 let others: Vec<&str> = found.others.iter().map(|e| e.label.as_str()).collect();
501 assert_eq!(others, ["personal", "side"]);
502
503 let found = line(&s, Some("codex-work"), &HashMap::new(), None, NOW);
504 assert_eq!(
505 found.current, None,
506 "a Codex identity names no Claude Code account"
507 );
508 }
509
510 #[test]
514 fn the_account_in_use_shows_the_newer_of_the_session_and_what_is_remembered() {
515 let remembered = HashMap::from([(
516 "work-uuid".to_string(),
517 reading(22.0, 6.0, NOW - 60, NOW + 600),
518 )]);
519 let shown = |input: &Value, remembered: &HashMap<String, Snapshot>| {
520 let offered = answered(input, remembered);
521 line(
522 &state(),
523 Some("work-uuid"),
524 remembered,
525 offered.as_ref(),
526 NOW,
527 )
528 .session
529 };
530 assert_eq!(
531 shown(&session(20.0, 5.0, NOW + 600), &remembered),
532 shares(22.0, 6.0)
533 );
534 assert_eq!(
535 shown(&session(25.0, 7.0, NOW + 600), &remembered),
536 shares(25.0, 7.0)
537 );
538
539 let next = HashMap::from([(
542 "work-uuid".to_string(),
543 reading(1.0, 6.0, NOW - 60, NOW + 5 * 3_600),
544 )]);
545 assert_eq!(shown(&session(90.0, 5.0, NOW - 1), &next), shares(1.0, 6.0));
546 }
547
548 #[test]
552 fn the_sessions_numbers_are_recorded_when_they_are_newer() {
553 let (ctx, _scratch) = machine("records");
554 crate::readings::remember(
555 &ctx,
556 &[("work-uuid".into(), reading(22.0, 6.0, NOW - 60, NOW + 600))],
557 );
558 run(&ctx, "busy", &session(22.0, 6.0, NOW + 600));
559 run(&ctx, "slow", &session(18.0, 4.0, NOW + 600));
560
561 let busy = run(&ctx, "busy", &session(25.0, 7.0, NOW + 600));
562 assert_eq!(busy.session, shares(25.0, 7.0));
563 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 7.0));
564
565 let slow = run(&ctx, "slow", &session(20.0, 5.0, NOW + 600));
566 assert_eq!(slow.session, shares(25.0, 7.0));
567 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 7.0));
568 }
569
570 #[test]
575 fn a_session_moves_the_numbers_and_leaves_what_only_anthropic_says() {
576 let (ctx, _scratch) = machine("leaves");
577 let mut answered = reading(22.0, 6.0, NOW - 60, NOW + 600);
578 answered.windows[0].severity = Some("normal".into());
579 answered.windows[0].is_active = true;
580 crate::readings::remember(&ctx, &[("work-uuid".into(), answered)]);
581
582 open(&ctx, "pane");
583 run(&ctx, "pane", &session(25.0, 70.0, NOW + 600));
584 let windows = recorded(&ctx).windows;
585 let said: Vec<(&str, f64, bool)> = windows
586 .iter()
587 .map(|w| (w.kind.as_str(), w.percent, w.is_active))
588 .collect();
589 assert_eq!(
590 said,
591 [("session", 25.0, true), ("weekly_all", 70.0, false)],
592 "under the names it had, and working against the limit it was"
593 );
594 assert_eq!(
595 windows[0].severity, None,
596 "a share Anthropic has not graded"
597 );
598 }
599
600 #[test]
605 fn a_session_holding_the_account_before_a_switch_is_not_recorded_as_the_one_after() {
606 let (ctx, _scratch) = machine("switched");
607 crate::state::save(&ctx, &state()).expect("an account index");
608 let work = |five: f64, weekly: f64, observed_at: i64| {
609 timed((five, NOW + 2 * HOUR), (weekly, NOW + 3 * DAY), observed_at)
610 };
611 crate::readings::remember(&ctx, &[("work-uuid".into(), work(10.0, 30.0, NOW - 60))]);
612 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
613 sign_in(&ctx, "personal");
614 open(&ctx, "pane");
615 run(&ctx, "pane", &personals);
616
617 sign_in(&ctx, "work");
618 for _ in 0..2 {
619 let idle = run(&ctx, "pane", &personals);
620 assert_eq!(idle.current.as_deref(), Some("work"));
621 assert_eq!(
622 idle.session,
623 shares(10.0, 30.0),
624 "the pane shows work's own"
625 );
626 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
627 }
628
629 let mut answered = work(12.0, 31.0, NOW);
630 answered.source = Source::Live;
631 crate::readings::remember(&ctx, &[("work-uuid".into(), answered)]);
632 assert_eq!(
633 shares_of(&recorded(&ctx), NOW),
634 shares(12.0, 31.0),
635 "and what Anthropic says about work is taken"
636 );
637 }
638
639 #[test]
643 fn a_session_holding_a_forgotten_accounts_numbers_is_not_recorded_as_the_one_in_use() {
644 let (ctx, _scratch) = machine("forgotten");
645 let mut accounts = state();
646 crate::state::save(&ctx, &accounts).expect("an account index");
647 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
648 sign_in(&ctx, "personal");
649 open(&ctx, "pane");
650 run(&ctx, "pane", &personals);
651 let works = timed(
652 (10.0, NOW + 2 * HOUR),
653 (30.0, NOW + 3 * DAY),
654 NOW - 2 * HOUR,
655 );
656 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
657
658 accounts.accounts[0].last_used_at = Some(NOW - HOUR);
659 crate::state::save(&ctx, &accounts).expect("an account index");
660 sign_in(&ctx, "work");
661 run(&ctx, "pane", &personals);
662 accounts.accounts.retain(|a| a.label != "personal");
663 crate::state::save(&ctx, &accounts).expect("an account index");
664 crate::readings::forget(&ctx, "personal-uuid");
665
666 let idle = run(&ctx, "pane", &personals);
667 assert_eq!(
668 idle.session,
669 shares(10.0, 30.0),
670 "the pane shows work's own"
671 );
672 let mut answered = timed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY), NOW);
673 answered.source = Source::Live;
674 crate::readings::remember(&ctx, &[("work-uuid".into(), answered)]);
675 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(12.0, 31.0));
676 }
677
678 #[test]
682 fn a_session_holding_an_unenrolled_accounts_numbers_is_not_recorded_after_a_login() {
683 let (ctx, _scratch) = machine("unenrolled");
684 crate::state::save(&ctx, &state()).expect("an account index");
685 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
686 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
687 let guests = passed((97.0, NOW + 4 * HOUR), (80.0, NOW + 6 * DAY));
688 sign_in(&ctx, "guest");
689 open(&ctx, "pane");
690 run(&ctx, "pane", &guests);
691
692 sign_in(&ctx, "work");
693 for _ in 0..2 {
694 let idle = run(&ctx, "pane", &guests);
695 assert_eq!(
696 idle.session,
697 shares(10.0, 30.0),
698 "the pane shows work's own"
699 );
700 }
701 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
702 }
703
704 #[test]
708 fn a_session_seen_for_the_first_time_offers_nothing_until_its_next_response() {
709 let (ctx, _scratch) = machine("first");
710 crate::state::save(&ctx, &state()).expect("an account index");
711 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
712 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
713
714 let first = run(
715 &ctx,
716 "pane",
717 &passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY)),
718 );
719 assert_eq!(
720 first.session,
721 shares(10.0, 30.0),
722 "the pane shows work's own"
723 );
724 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
725
726 let next = run(
727 &ctx,
728 "pane",
729 &passed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
730 );
731 assert_eq!(next.session, shares(12.0, 31.0));
732 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(12.0, 31.0));
733 }
734
735 #[test]
740 fn what_a_session_passes_as_the_account_named_changes_is_not_recorded() {
741 let (ctx, _scratch) = machine("changed");
742 crate::state::save(&ctx, &state()).expect("an account index");
743 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
744 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
745 sign_in(&ctx, "personal");
746 open(&ctx, "pane");
747 run(
748 &ctx,
749 "pane",
750 &passed((100.0, NOW - HOUR), (50.0, NOW + 5 * DAY)),
751 );
752
753 sign_in(&ctx, "work");
754 let changed = run(
755 &ctx,
756 "pane",
757 &passed((2.0, NOW + 4 * HOUR), (51.0, NOW + 5 * DAY)),
758 );
759 assert_eq!(
760 changed.session,
761 shares(10.0, 30.0),
762 "the pane shows work's own"
763 );
764 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
765 }
766
767 #[test]
771 fn nothing_is_recorded_while_sessions_are_still_taking_up_a_switch() {
772 let (ctx, _scratch) = machine("adopting");
773 let mut switched = state();
774 switched.accounts[0].last_used_at = Some(NOW - 10);
775 crate::state::save(&ctx, &switched).expect("an account index");
776 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
777 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
778 open(&ctx, "pane");
779
780 let unread = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
781 let shown = run(&ctx, "pane", &unread);
782 assert_eq!(
783 shown.session,
784 shares(10.0, 30.0),
785 "the pane shows work's own"
786 );
787 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
788
789 switched.accounts[0].last_used_at =
790 Some(NOW - i64::from(crate::switch::ADOPTION_CEILING_SECONDS));
791 crate::state::save(&ctx, &switched).expect("an account index");
792 run(
793 &ctx,
794 "pane",
795 &passed((12.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
796 );
797 assert_eq!(
798 shares_of(&recorded(&ctx), NOW),
799 shares(12.0, 31.0),
800 "and once they have, what they say is"
801 );
802 }
803
804 #[test]
808 fn nothing_is_recorded_in_the_second_the_account_is_put_to_use() {
809 let (ctx, _scratch) = machine("same-second");
810 let mut switched = state();
811 crate::state::save(&ctx, &switched).expect("an account index");
812 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
813 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
814 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
815 sign_in(&ctx, "personal");
816 run(&at(&ctx, NOW - 60), "pane", &personals);
817
818 switched.accounts[0].last_used_at = Some(NOW);
819 crate::state::save(&ctx, &switched).expect("an account index");
820 sign_in(&ctx, "work");
821 run(&ctx, "pane", &personals);
822 run(
823 &ctx,
824 "pane",
825 &passed((2.0, NOW + 5 * HOUR), (61.0, NOW + 5 * DAY)),
826 );
827 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
828 }
829
830 #[test]
835 fn a_response_from_the_account_before_a_login_is_known_by_its_windows() {
836 let (ctx, _scratch) = machine("login");
837 crate::state::save(&ctx, &state()).expect("an account index");
838 let works = timed((10.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60);
839 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
840 sign_in(&ctx, "personal");
841 open(&ctx, "pane");
842 let personals = passed((95.0, NOW + 4 * HOUR), (60.0, NOW + 5 * DAY));
843 run(&ctx, "pane", &personals);
844
845 sign_in(&ctx, "work");
846 run(&ctx, "pane", &personals);
847 run(
848 &ctx,
849 "pane",
850 &passed((96.0, NOW + 4 * HOUR), (61.0, NOW + 5 * DAY)),
851 );
852 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(10.0, 30.0));
853 }
854
855 #[test]
859 fn a_window_past_its_reset_is_not_recorded() {
860 let (ctx, _scratch) = machine("passed");
861 crate::state::save(&ctx, &state()).expect("an account index");
862 open(&ctx, "pane");
863 run(
864 &ctx,
865 "pane",
866 &passed((90.0, NOW - 60), (40.0, NOW + 3 * DAY)),
867 );
868 let kinds: Vec<String> = recorded(&ctx).windows.into_iter().map(|w| w.kind).collect();
869 assert_eq!(kinds, ["weekly_all"]);
870 }
871
872 #[test]
879 fn a_window_that_resets_stays_in_the_reading_with_nothing_used() {
880 let (ctx, _scratch) = machine("reset");
881 crate::state::save(&ctx, &state()).expect("an account index");
882 let works = timed((40.0, NOW - 10), (30.0, NOW + 3 * DAY), NOW - HOUR);
883 crate::readings::remember(&ctx, &[("work-uuid".into(), works)]);
884 let floor = crate::budget::floor_for(Some(&recorded(&ctx)));
885 run(
886 &at(&ctx, NOW - 60),
887 "pane",
888 &passed((40.0, NOW - 10), (30.0, NOW + 3 * DAY)),
889 );
890
891 let shown = run(
892 &ctx,
893 "pane",
894 &passed((41.0, NOW - 10), (31.0, NOW + 3 * DAY)),
895 );
896 assert_eq!(shown.session, shares(0.0, 31.0));
897 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(0.0, 31.0));
898 let offline = crate::status::gather_offline(&ctx, &state());
899 let row = offline
900 .rows
901 .iter()
902 .find(|r| r.account_uuid == "work-uuid")
903 .and_then(|r| r.usage.as_ref())
904 .expect("work's numbers");
905 let kinds: Vec<&str> = row.windows.iter().map(|w| w.kind.as_str()).collect();
906 assert_eq!(kinds, ["session", "weekly_all"], "the row is kept");
907 assert_eq!(crate::budget::floor_for(Some(&recorded(&ctx))), floor);
908 }
909
910 #[test]
914 fn a_session_still_records_its_own_accounts_next_window() {
915 let (ctx, _scratch) = machine("next");
916 crate::state::save(&ctx, &state()).expect("an account index");
917 crate::readings::remember(
918 &ctx,
919 &[
920 (
921 "work-uuid".into(),
922 timed((100.0, NOW - 60), (40.0, NOW + 3 * DAY), NOW - HOUR),
923 ),
924 (
925 "personal-uuid".into(),
926 timed((20.0, NOW + 4 * HOUR), (50.0, NOW + 5 * DAY), NOW - 60),
927 ),
928 ],
929 );
930 run(
931 &ctx,
932 "pane",
933 &passed((100.0, NOW - 60), (40.0, NOW + 3 * DAY)),
934 );
935 let shown = run(
936 &ctx,
937 "pane",
938 &passed((2.0, NOW + 5 * HOUR), (41.0, NOW + 3 * DAY)),
939 );
940 assert_eq!(shown.session, shares(2.0, 41.0));
941 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(2.0, 41.0));
942 }
943
944 #[test]
947 fn a_window_both_accounts_have_is_still_offered() {
948 let (ctx, _scratch) = machine("coincident");
949 crate::state::save(&ctx, &state()).expect("an account index");
950 crate::readings::remember(
951 &ctx,
952 &[
953 (
954 "work-uuid".into(),
955 timed((20.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY), NOW - 60),
956 ),
957 (
958 "personal-uuid".into(),
959 timed((50.0, NOW + 2 * HOUR + 30), (60.0, NOW + 5 * DAY), NOW - 60),
960 ),
961 ],
962 );
963 run(
964 &ctx,
965 "pane",
966 &passed((20.0, NOW + 2 * HOUR), (30.0, NOW + 3 * DAY)),
967 );
968 let shown = run(
969 &ctx,
970 "pane",
971 &passed((25.0, NOW + 2 * HOUR), (31.0, NOW + 3 * DAY)),
972 );
973 assert_eq!(shown.session, shares(25.0, 31.0));
974 assert_eq!(shares_of(&recorded(&ctx), NOW), shares(25.0, 31.0));
975 }
976}