The allowance: the rolling thinking window and the daily voice cap.

3use ::log::info;
4use whiskers_core::TokenLimit;
5use whiskers_ports::{Allowance, Day, Diagnostic, Millis, ReserveError, Settlement, SpeakError, ThinkingRoom, Tokens, run_ready};
7const H: u64 = 3_600_000;
8const DAY: u64 = 86_400_000;
9
10fn limit(tokens: u64, hours: u64) -> TokenLimit {
11    TokenLimit::new(Some(tokens), hours).unwrap()
12}
13
14fn at(ms: u64) -> Millis {
15    Millis::new(ms)
16}

Runs every allowance law.

19pub fn allowance<A: Allowance>(fresh: &impl Fn() -> A, restart: &impl Fn(A) -> A) {
20    info!("conformance: allowance");
21    thinking_has_room_until_the_window_is_spent(fresh());
22    thinking_spending_ages_out_of_a_rolling_window(fresh());
23    thinking_a_changed_limit_applies_at_once(fresh());
24    thinking_usage_says_when_room_returns(fresh());
25    thinking_spending_survives_a_restart(fresh, restart);
26    voice_a_line_that_would_pass_the_cap_is_refused(fresh());
27    voice_a_reservation_counts_before_it_is_settled(fresh());
28    voice_a_line_that_did_not_come_out_is_given_back_and_remembered(fresh());
29    voice_a_line_that_was_spoken_stays_spent_and_clears_the_last_failure(fresh());
30    voice_a_new_day_is_a_fresh_allowance(fresh());
31    voice_spending_survives_a_restart(fresh, restart);
32}
34pub fn thinking_has_room_until_the_window_is_spent<A: Allowance>(a: A) {
35    let l = limit(1000, 5);
36    assert_eq!(run_ready(a.thinking_room(at(0), &l)).unwrap(), ThinkingRoom::Room);
37    run_ready(a.charge_thinking(at(0), Tokens::new(600))).unwrap();
38    assert_eq!(run_ready(a.thinking_room(at(1), &l)).unwrap(), ThinkingRoom::Room, "600 of 1000");
39    run_ready(a.charge_thinking(at(1), Tokens::new(600))).unwrap();
40    assert!(matches!(run_ready(a.thinking_room(at(2), &l)).unwrap(), ThinkingRoom::Spent { .. }), "1200 of 1000: asked first, charged after, so the last may overshoot");
41    assert_eq!(run_ready(a.thinking_room(at(2), &TokenLimit::new(None, 5).unwrap())).unwrap(), ThinkingRoom::Room, "no limit at all");
42}
43
44pub fn thinking_spending_ages_out_of_a_rolling_window<A: Allowance>(a: A) {
45    let l = limit(1000, 5);
46    run_ready(a.charge_thinking(at(0), Tokens::new(1000))).unwrap();
47    assert_eq!(run_ready(a.thinking_room(at(4 * H), &l)).unwrap(), ThinkingRoom::Spent { frees_up_at: at(5 * H) });
48    assert_eq!(run_ready(a.thinking_room(at(5 * H + 1), &l)).unwrap(), ThinkingRoom::Room, "a rolling window, not a clock day");
49    assert_eq!(run_ready(a.thinking_usage(at(5 * H + 1), &l)).unwrap().used, Tokens::new(0));
50}
51
52pub fn thinking_a_changed_limit_applies_at_once<A: Allowance>(a: A) {
53    run_ready(a.charge_thinking(at(0), Tokens::new(900))).unwrap();
54    assert!(matches!(run_ready(a.thinking_room(at(H), &limit(500, 5))).unwrap(), ThinkingRoom::Spent { .. }));
55    assert_eq!(run_ready(a.thinking_room(at(H), &limit(5000, 5))).unwrap(), ThinkingRoom::Room);
56    assert_eq!(run_ready(a.thinking_room(at(3 * H), &limit(500, 2))).unwrap(), ThinkingRoom::Room, "a shorter window forgets the spending sooner");
57}
58
59pub fn thinking_usage_says_when_room_returns<A: Allowance>(a: A) {
60    let l = limit(1000, 5);
61    let u = run_ready(a.thinking_usage(at(0), &l)).unwrap();
62    assert_eq!((u.used, u.limit, u.window_hours, u.frees_up_at), (Tokens::new(0), Some(Tokens::new(1000)), 5, None));
63    run_ready(a.charge_thinking(at(2 * H), Tokens::new(300))).unwrap();
64    run_ready(a.charge_thinking(at(3 * H), Tokens::new(300))).unwrap();
65    let u = run_ready(a.thinking_usage(at(4 * H), &l)).unwrap();
66    assert_eq!((u.used, u.frees_up_at), (Tokens::new(600), Some(at(7 * H))), "when the oldest spending leaves");
67}
68
69pub fn thinking_spending_survives_a_restart<A: Allowance>(fresh: &impl Fn() -> A, restart: &impl Fn(A) -> A) {
70    let a = fresh();
71    run_ready(a.charge_thinking(at(10), Tokens::new(700))).unwrap();
72    let a = restart(a);
73    assert_eq!(run_ready(a.thinking_usage(at(20), &limit(1000, 5))).unwrap().used, Tokens::new(700), "a restart must not grant a fresh allowance");
74}
75
76pub fn voice_a_line_that_would_pass_the_cap_is_refused<A: Allowance>(a: A) {
77    let now = at(DAY * 7 + 1000);
78    let r = run_ready(a.reserve_voice(now, 6, 10)).unwrap();
79    run_ready(a.settle_voice(r, Settlement::Spoken)).unwrap();
80    match run_ready(a.reserve_voice(at(DAY * 7 + 2000), 6, 10)) {
81        Err(ReserveError::DailyCap { spent, cap, frees_up_at }) => {
82            assert_eq!((spent, cap), (6, 10));
83            assert_eq!(frees_up_at, Day::containing(now).ends_at(), "a spent day frees up when the next one begins");
84        }
85        other => panic!("expected the daily cap, got {other:?}"),
86    }
87    let r = run_ready(a.reserve_voice(at(DAY * 7 + 3000), 4, 10)).expect("exactly the cap is allowed");
88    run_ready(a.settle_voice(r, Settlement::Spoken)).unwrap();
89}
90
91pub fn voice_a_reservation_counts_before_it_is_settled<A: Allowance>(a: A) {
92    // Two lines asked for at once must not both pass a cap with room for one.
93    let first = run_ready(a.reserve_voice(at(1000), 6, 10)).unwrap();
94    assert!(matches!(run_ready(a.reserve_voice(at(1001), 6, 10)), Err(ReserveError::DailyCap { .. })), "the first is still outstanding");
95    assert_eq!(run_ready(a.voice_spend(at(1002))).unwrap().spent_today, 6, "and is already spent");
96    run_ready(a.settle_voice(first, Settlement::Spoken)).unwrap();
97}
98
99pub fn voice_a_line_that_did_not_come_out_is_given_back_and_remembered<A: Allowance>(a: A) {
100    let r = run_ready(a.reserve_voice(at(1000), 6, 10)).unwrap();
101    let why = SpeakError::Unreachable(Diagnostic::new("the voice is down"));
102    run_ready(a.settle_voice(r, Settlement::NotSpoken(why.clone()))).unwrap();
103    let spend = run_ready(a.voice_spend(at(1001))).unwrap();
104    assert_eq!(spend.spent_today, 0, "the characters came back");
105    assert_eq!(spend.last_failure, Some(why), "and the parents can see why the voice fell silent");
106    assert!(run_ready(a.reserve_voice(at(1002), 6, 10)).is_ok(), "so the same line can be tried again");
107}
108
109pub fn voice_a_line_that_was_spoken_stays_spent_and_clears_the_last_failure<A: Allowance>(a: A) {
110    let r = run_ready(a.reserve_voice(at(1000), 3, 10)).unwrap();
111    run_ready(a.settle_voice(r, Settlement::NotSpoken(SpeakError::TooLong))).unwrap();
112    assert!(run_ready(a.voice_spend(at(1001))).unwrap().last_failure.is_some());
113    let r = run_ready(a.reserve_voice(at(1002), 4, 10)).unwrap();
114    run_ready(a.settle_voice(r, Settlement::Spoken)).unwrap();
115    let spend = run_ready(a.voice_spend(at(1003))).unwrap();
116    assert_eq!((spend.spent_today, spend.last_failure), (4, None), "one that comes out clears the last failure");
117}
118
119pub fn voice_a_new_day_is_a_fresh_allowance<A: Allowance>(a: A) {
120    let today = at(DAY * 3 + 5);
121    let r = run_ready(a.reserve_voice(today, 10, 10)).unwrap();
122    run_ready(a.settle_voice(r, Settlement::Spoken)).unwrap();
123    assert!(run_ready(a.reserve_voice(at(DAY * 3 + 6), 1, 10)).is_err());
124    assert_eq!(run_ready(a.voice_spend(at(DAY * 4))).unwrap().spent_today, 0, "the screen shows the day it is asked about");
125    let r = run_ready(a.reserve_voice(at(DAY * 4), 10, 10)).expect("tomorrow starts from zero");
126    run_ready(a.settle_voice(r, Settlement::Spoken)).unwrap();
127}
128
129pub fn voice_spending_survives_a_restart<A: Allowance>(fresh: &impl Fn() -> A, restart: &impl Fn(A) -> A) {
130    let a = fresh();
131    let r = run_ready(a.reserve_voice(at(1000), 6, 10)).unwrap();
132    run_ready(a.settle_voice(r, Settlement::Spoken)).unwrap();
133    let a = restart(a);
134    assert_eq!(run_ready(a.voice_spend(at(1001))).unwrap().spent_today, 6);
135    assert!(
136        matches!(run_ready(a.reserve_voice(at(1002), 6, 10)), Err(ReserveError::DailyCap { .. })),
137        "a restart (or a rebuild) must not grant the day's allowance twice"
138    );
139}