1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
//! #69 regression tests: interrupt-delivered turns must re-stick the plan
//! card through the SAME settle-path tail as normal settles.
//!
//! Before #69, a preempted turn early-returned through the cancel_token
//! teardown (`return Ok(())`) before the #62 re-stick block ran, so during an
//! interrupt chain every reply skipped the restick and the card stayed buried
//! at a stale position until a normal settle finally happened. The fix routes
//! the teardown through `plan_card::restick_plan_card_after_turn` — the exact
//! block the normal settle runs.
//!
//! These tests pin the three seams the interrupt tail must satisfy, without
//! touching the network or the Bot:
//! 1. The interrupt restick draws from the SAME shared sticky-stack budget
//! (#1150) as normal settles — no independent gate, no bypass.
//! 2. A cardless turn spends NOTHING from that budget (the tracked-card claim
//! gates it) — the flow-block restick must not starve for 15s after every
//! interrupt-delivered cardless reply (#62 lesson).
//! 3. The keyboard the interrupt path passes is recomputed for a FINISHED
//! turn (`turn_active == false` via `load_plan_state_section`), so the
//! Approve/Discard keyboard materializes on the resticked card exactly as
//! it does on the normal settle path (#571 gate).
//!
//! Fixtures are synthetic and carry no user identifiers.
use crate::channels::telegram::flow_chrome::{PlanKb, plan_state_chrome};
use crate::channels::telegram::state::TelegramState;
use crate::utils::plan_files::PlanModeState;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
/// Seam 1 + 2: the interrupt tail calls `claim_sticky_action` with the same
/// shared `STICKY_STACK_MIN_INTERVAL` the settle path uses, gated on a tracked
/// card. Pinned against the real state object: a chat whose sticky action was
/// just spent must refuse the claim within the interval (budget is shared,
/// regardless of which path spent it), and a session with no tracked card
/// never reaches the claim at all (`plan_card_cached` is the gate).
#[tokio::test]
async fn interrupt_restick_shares_the_sticky_budget_and_spends_nothing_cardless() {
let state = Arc::new(TelegramState::new());
let session = Uuid::new_v4();
let chat = teloxide::types::ChatId(690_001);
// Seam 2: cardless — the exact gate the tail evaluates before claiming.
assert!(
state.plan_card_cached(session).await.is_none(),
"cardless session must not reach the sticky claim at all"
);
// Simulate a settle-path restick having just spent the budget (the normal
// path claims via the same helper): the shared budget is now hot.
assert!(
state.claim_sticky_action(chat.0, TelegramState::STICKY_STACK_MIN_INTERVAL),
"first claim (simulated normal settle) must be admitted"
);
// The interrupt tail's claim, moments later: REFUSED — the two paths draw
// from ONE budget, this is the flood-safety contract (#1150, #814 stays
// deleted: no cooldown is recorded on a skip, but a real burst refuses).
assert!(
!state.claim_sticky_action(chat.0, TelegramState::STICKY_STACK_MIN_INTERVAL),
"interrupt-path restick must share the settle-path sticky budget"
);
// After the interval elapses the budget frees up for the next restick,
// whichever path needs it. (Same seam the settle path uses; pinned here so
// a future per-path budget split fails loudly.)
std::thread::sleep(TelegramState::STICKY_STACK_MIN_INTERVAL + Duration::from_millis(5));
assert!(
state.claim_sticky_action(chat.0, TelegramState::STICKY_STACK_MIN_INTERVAL),
"budget must free up after the interval for the next restick"
);
}
/// Seam 3: the interrupt path recomputes the keyboard for a finished turn.
/// The teardown calls `load_plan_state_section(session_id, false)` — the
/// turn is over, so `turn_active == false` and the PostInitEditing state
/// yields the Approve/Discard keyboard, mirroring the settle path's
/// `refresh_sections` recompute (#571). Pinned at the pure seam
/// (`plan_state_chrome`) the loader delegates to, with the live plan files
/// absent (NoPlan is the state any CI environment sees).
#[test]
fn interrupt_tail_recomputes_keyboard_for_a_finished_turn() {
// What the teardown computes: turn finished (turn_active == false).
let (label, kb) = plan_state_chrome(PlanModeState::PostInitEditing, false, false);
assert_eq!(label, Some("✍️ Editing plan".to_string()));
assert_eq!(
kb,
PlanKb::ApproveDiscard,
"the resticked card must carry the Approve/Discard keyboard after an \
interrupt-delivered turn ends, exactly like a normal settle (#571)"
);
// Contrast: the same state mid-turn (a concurrent in-flight turn elsewhere
// recomputed during the interrupt window) strips the keyboard.
assert_eq!(
plan_state_chrome(PlanModeState::PostInitEditing, true, false).1,
PlanKb::None,
"an in-flight turn must still render keyboardless chrome"
);
}