use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use iris_chat_core::{AppAction, AppReconciler, AppState, AppUpdate, FfiApp};
use tempfile::TempDir;
#[test]
fn cold_start_account_creation_stays_under_budget() {
let dir = TempDir::new().unwrap();
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
let baseline = app.perf_counters();
let _ = app.state();
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let after = app.perf_counters();
let delta = countDelta(&baseline, &after);
assert!(
delta.state <= 2,
"FFI state() polling regression: {delta:?}"
);
assert_eq!(delta.dispatch, 1, "{delta:?}");
assert_eq!(delta.search, 0, "{delta:?}");
assert_eq!(delta.peer_profile_debug, 0, "{delta:?}");
}
#[test]
fn idle_post_open_chat_emits_no_extra_ffi_calls() {
let dir = TempDir::new().unwrap();
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "Bob");
app.dispatch(AppAction::CreateChat {
peer_input: bob_npub,
});
inbox.wait_until(Duration::from_secs(5), |state| state.current_chat.is_some());
let before = app.perf_counters();
std::thread::sleep(Duration::from_millis(50));
let after = app.perf_counters();
let delta = countDelta(&before, &after);
assert_eq!(
delta.state, 0,
"FFI state() polled while shell idle: {delta:?}",
);
assert_eq!(
delta.search, 0,
"FFI search() fired while shell idle (chat-list search regression): {delta:?}",
);
assert_eq!(
delta.dispatch, 0,
"FFI dispatch() fired with no user input: {delta:?}",
);
}
#[test]
fn search_keystrokes_fire_one_search_call_each() {
let dir = TempDir::new().unwrap();
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let before = app.perf_counters();
for prefix_len in 1..=5 {
let query: String = "hello".chars().take(prefix_len).collect();
let _ = app.search(query, None, 20);
}
let after = app.perf_counters();
let delta = countDelta(&before, &after);
assert_eq!(
delta.search, 5,
"expected one search per keystroke; got {delta:?}",
);
}
#[test]
fn debug_snapshot_rebuilds_stay_under_budget_during_message_burst() {
let dir = TempDir::new().unwrap();
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "Bob");
app.dispatch(AppAction::CreateChat {
peer_input: bob_npub,
});
inbox.wait_until(Duration::from_secs(5), |state| state.current_chat.is_some());
let chat_id = inbox.snapshot().current_chat.unwrap().chat_id;
let before = app.core_perf_counters();
for index in 0..5 {
app.dispatch(AppAction::SendMessage {
chat_id: chat_id.clone(),
text: format!("burst {index}"),
});
}
inbox.wait_until(Duration::from_secs(5), |state| {
state
.current_chat
.as_ref()
.map(|chat| chat.messages.len() >= 5)
.unwrap_or(false)
});
let after = app.core_perf_counters();
let rebuilds = after.debug_snapshot_builds - before.debug_snapshot_builds;
assert!(
rebuilds <= 2,
"debug_snapshot rebuilt {rebuilds} times for 5 messages; throttle regressed (was per-event before the fix). before={before:?} after={after:?}",
);
}
#[test]
fn receiving_messages_does_not_call_ffi() {
let dir = TempDir::new().unwrap();
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "Bob");
app.dispatch(AppAction::CreateChat {
peer_input: bob_npub.clone(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.current_chat.is_some());
let chat_id = inbox.snapshot().current_chat.unwrap().chat_id;
let before = app.perf_counters();
for index in 0..5 {
app.dispatch(AppAction::SendMessage {
chat_id: chat_id.clone(),
text: format!("ping {index}"),
});
}
inbox.wait_until(Duration::from_secs(5), |state| {
state
.current_chat
.as_ref()
.map(|chat| chat.messages.len() >= 5)
.unwrap_or(false)
});
let after = app.perf_counters();
let delta = countDelta(&before, &after);
assert_eq!(
delta.search, 0,
"search() must not fire on message receipt: {delta:?}",
);
assert_eq!(
delta.state, 0,
"state() polling on message receipt: {delta:?}",
);
assert_eq!(delta.dispatch, 5, "{delta:?}");
}
#[allow(non_snake_case)]
fn countDelta(
before: &iris_chat_core::FfiPerfCountersSnapshot,
after: &iris_chat_core::FfiPerfCountersSnapshot,
) -> iris_chat_core::FfiPerfCountersSnapshot {
iris_chat_core::FfiPerfCountersSnapshot {
state: after.state - before.state,
dispatch: after.dispatch - before.dispatch,
search: after.search - before.search,
ingest_nearby_event_json: after.ingest_nearby_event_json - before.ingest_nearby_event_json,
export_support_bundle_json: after.export_support_bundle_json
- before.export_support_bundle_json,
peer_profile_debug: after.peer_profile_debug - before.peer_profile_debug,
mutual_groups: after.mutual_groups - before.mutual_groups,
prepare_for_suspend: after.prepare_for_suspend - before.prepare_for_suspend,
}
}
#[derive(Clone)]
struct ReconcilerInbox {
state: Arc<Mutex<AppState>>,
}
impl ReconcilerInbox {
fn install(app: &FfiApp) -> Self {
let inbox = Self {
state: Arc::new(Mutex::new(AppState::empty())),
};
let collector = Box::new(StateCollector {
slot: inbox.state.clone(),
});
app.listen_for_updates(collector);
inbox
}
fn wait_until<F>(&self, timeout: Duration, mut predicate: F)
where
F: FnMut(&AppState) -> bool,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Ok(guard) = self.state.lock() {
if predicate(&guard) {
return;
}
}
std::thread::sleep(Duration::from_millis(2));
}
panic!("predicate never observed within {timeout:?}");
}
fn snapshot(&self) -> AppState {
self.state.lock().unwrap().clone()
}
}
struct StateCollector {
slot: Arc<Mutex<AppState>>,
}
impl AppReconciler for StateCollector {
fn reconcile(&self, update: AppUpdate) {
if let AppUpdate::FullState(state) = update {
if let Ok(mut guard) = self.slot.lock() {
if state.rev >= guard.rev {
*guard = state;
}
}
}
}
}
fn ensure_account(temp: &TempDir, name: &str) -> String {
let app = FfiApp::new(
temp.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: name.to_string(),
});
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if Instant::now() > deadline {
panic!("account creation timeout for {name}");
}
if let Some(account) = inbox.state.lock().unwrap().account.clone() {
return account.npub;
}
std::thread::sleep(Duration::from_millis(2));
}
}