From 9eb393bc6431c45449451f2491c7459021334c42 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:37:14 +0000 Subject: [PATCH] test: add 87 integration tests filling bot + dispatch + events coverage gaps Adds six focused integration test files covering under-tested behaviour surfaced by `scripts/audit_teamtalk_coverage.py` and a manual walk through `crates/teamtalk/tests/`. - `bot_fsm_extra_tests.rs` (19): DialogState defaults, expiry boundary, timeout policy encode/decode round-trip, metadata replace/remove, legacy pipe decode, DialogFlow navigation (next/previous/start/terminal), DialogMachine session ids, advance after pause, Clear vs Pause timeout policy, set/clear timeout, metadata persistence across reloads, custom prefix namespacing, is_in ignores paused, restart_flow rotates session. - `dispatcher_extra_tests.rs` (11): empty queue behaviour, event-specific filtering, wildcard firing, Stop flow from any slot, shortcut handlers (connect/connection-lost/failed/cmd-error/user-joined/text-message), ordering of multiple specific handlers, specific+wildcard insertion order, Stop semantics (surfaces via step() but siblings still run). - `events_error_extra_tests.rs` (14): Event::is_reconnect_needed and is_reconnect_needed_with (discriminant match, payload irrelevance, empty extras), ConnectionState defaults and Joining(ChannelId) inequality, Error Display for each variant, IoError/ClientError message fields, FfiError Display per variant, From cascade through Error::Ffi, SdkError Clone payload round-trip, Event::Unknown ClientEvent payload. - `bot_state_v2_extra_tests.rs` (13): set overwrite without growth, set_with_ttl replaces permanent, set clears TTL, remove on expired entry still evicts (documents current behaviour: returns stale value but frees slot), remove_prefix 0-match case, empty prefix removes all, keys with empty prefix, exists for expired, get_many preserves request order, set_many empty batch and overwrites, get missing/expired, remove_prefix with mixed TTL expiry. - `scheduler_tests.rs` (11): empty scheduler sentinel delay, every_named and every (unique auto-names), remove idempotence, set_enabled toggle, set_enabled on missing job is noop, is_enabled returns None for missing, recurring job fires multiple times, after job is one-shot and is removed after running, disabled job skipped, next_run_delay only considers enabled jobs. - `bot_command_extra_tests.rs` (19): parse_command multi-prefix, empty and prefix-only rejection, argument order/count, whitespace collapsing; CommandPattern::parse rejection of empty/required-after-optional/ variadic-not-last/command-token-after-args; min/max arity for fixed and variadic tails; accepts() validation; descriptor flags; multi-word command names; usage bracketing and prefix insertion; Args raw/rest/ get-parse-error/missing/all. Total: 287 -> 374 tests (+87) on this branch. All pass under `cargo test --workspace --all-features`; no changes to src/ or public API. --- .../teamtalk/tests/bot_command_extra_tests.rs | 152 ++++++++++ crates/teamtalk/tests/bot_fsm_extra_tests.rs | 269 ++++++++++++++++++ .../tests/bot_state_v2_extra_tests.rs | 140 +++++++++ .../teamtalk/tests/dispatcher_extra_tests.rs | 246 ++++++++++++++++ .../tests/events_error_extra_tests.rs | 156 ++++++++++ crates/teamtalk/tests/scheduler_tests.rs | 197 +++++++++++++ 6 files changed, 1160 insertions(+) create mode 100644 crates/teamtalk/tests/bot_command_extra_tests.rs create mode 100644 crates/teamtalk/tests/bot_fsm_extra_tests.rs create mode 100644 crates/teamtalk/tests/bot_state_v2_extra_tests.rs create mode 100644 crates/teamtalk/tests/dispatcher_extra_tests.rs create mode 100644 crates/teamtalk/tests/events_error_extra_tests.rs create mode 100644 crates/teamtalk/tests/scheduler_tests.rs diff --git a/crates/teamtalk/tests/bot_command_extra_tests.rs b/crates/teamtalk/tests/bot_command_extra_tests.rs new file mode 100644 index 0000000..1679845 --- /dev/null +++ b/crates/teamtalk/tests/bot_command_extra_tests.rs @@ -0,0 +1,152 @@ +#![cfg(feature = "bot")] +//! Additional coverage for `Command` parsing, `CommandPattern` matching, and +//! `Args` helpers beyond the baseline in `bot_primitives.rs`. + +use teamtalk::{Args, CommandPattern, parse_command}; + +#[test] +fn parse_command_accepts_multiple_prefix_variants() { + let slash = parse_command("/ping", &['/', '!']).expect("slash prefix"); + assert_eq!(slash.prefix, '/'); + assert_eq!(slash.name, "ping"); + + let bang = parse_command("!ping", &['/', '!']).expect("bang prefix"); + assert_eq!(bang.prefix, '!'); + assert_eq!(bang.name, "ping"); +} + +#[test] +fn parse_command_rejects_empty_input_and_prefix_only() { + assert!(parse_command("", &['/']).is_none()); + // A bare prefix without a name is not a command. + assert!(parse_command("/", &['/']).is_none()); +} + +#[test] +fn parse_command_preserves_argument_order_and_count() { + let cmd = parse_command("/kick alice spammer bye", &['/']).unwrap(); + assert_eq!(cmd.name, "kick"); + assert_eq!(cmd.args, vec!["alice", "spammer", "bye"]); + assert_eq!(cmd.arg(0), Some("alice")); + assert_eq!(cmd.arg(99), None); +} + +#[test] +fn parse_command_collapses_extra_whitespace_between_tokens() { + let cmd = parse_command("/say hello world", &['/']).unwrap(); + assert_eq!(cmd.name, "say"); + assert_eq!(cmd.args, vec!["hello", "world"]); +} + +#[test] +fn command_pattern_parse_rejects_empty_input() { + assert!(CommandPattern::parse("").is_err()); + assert!(CommandPattern::parse(" ").is_err()); +} + +#[test] +fn command_pattern_parse_rejects_required_after_optional() { + assert!(CommandPattern::parse("ban [reason] ").is_err()); +} + +#[test] +fn command_pattern_parse_rejects_variadic_not_last() { + assert!(CommandPattern::parse("say ").is_err()); +} + +#[test] +fn command_pattern_parse_rejects_command_token_after_args() { + assert!(CommandPattern::parse("ban more-command").is_err()); +} + +#[test] +fn command_pattern_min_and_max_args_for_fixed_arity() { + let pat = CommandPattern::parse("kick [reason]").unwrap(); + assert_eq!(pat.min_args(), 1); + assert_eq!(pat.max_args(), Some(2)); +} + +#[test] +fn command_pattern_max_args_none_for_variadic_tail() { + let pat = CommandPattern::parse("say ").unwrap(); + assert_eq!(pat.min_args(), 1); + assert_eq!(pat.max_args(), None); +} + +#[test] +fn command_pattern_accepts_validates_arg_counts() { + let pat = CommandPattern::parse("move [channel]").unwrap(); + assert!(!pat.accepts(&[])); + assert!(pat.accepts(&["alice".into()])); + assert!(pat.accepts(&["alice".into(), "lobby".into()])); + assert!(!pat.accepts(&["alice".into(), "lobby".into(), "extra".into()])); +} + +#[test] +fn command_pattern_accepts_unbounded_for_variadic_tail() { + let pat = CommandPattern::parse("echo ").unwrap(); + for n in 1..10 { + let args: Vec = (0..n).map(|i| i.to_string()).collect(); + assert!(pat.accepts(&args), "accepts must be true for {n} args"); + } +} + +#[test] +fn command_pattern_args_descriptors_expose_required_and_variadic_flags() { + let pat = CommandPattern::parse("kick [reason...]").unwrap(); + let args = pat.args(); + assert_eq!(args.len(), 2); + assert!(args[0].required()); + assert!(!args[0].variadic()); + assert!(!args[1].required()); + assert!(args[1].variadic()); +} + +#[test] +fn command_pattern_command_parts_support_multi_word_command_names() { + let pat = CommandPattern::parse("admin ban ").unwrap(); + assert_eq!(pat.command_parts(), &["admin", "ban"]); + assert_eq!(pat.command(), "admin ban"); + assert_eq!(pat.usage(), "admin ban "); +} + +#[test] +fn command_pattern_usage_strings_include_bracketing_conventions() { + let pat = CommandPattern::parse("kick [reason]").unwrap(); + assert_eq!(pat.usage(), "kick [reason]"); + assert_eq!(pat.usage_with_prefix('!'), "!kick [reason]"); +} + +#[test] +fn args_raw_returns_original_slice_values() { + let raw = vec!["alpha".to_owned(), "beta".to_owned()]; + let args = Args::new(&raw); + assert_eq!(args.raw(0), Some("alpha")); + assert_eq!(args.raw(1), Some("beta")); + assert_eq!(args.raw(2), None); +} + +#[test] +fn args_rest_returns_none_past_end() { + let raw = vec!["a".to_owned()]; + let args = Args::new(&raw); + assert_eq!(args.rest(5), None); + assert_eq!(args.rest(0).as_deref(), Some("a")); +} + +#[test] +fn args_get_reports_parse_errors_and_missing_values() { + let raw = vec!["fifty".to_owned()]; + let args = Args::new(&raw); + // Present but not numeric → Err. + assert!(args.get::(0).is_err()); + // Missing index → Ok(None). + assert!(matches!(args.get::(1), Ok(None))); +} + +#[test] +fn args_all_returns_underlying_slice() { + let raw = vec!["x".to_owned(), "y".to_owned()]; + let args = Args::new(&raw); + assert_eq!(args.all(), raw.as_slice()); +} diff --git a/crates/teamtalk/tests/bot_fsm_extra_tests.rs b/crates/teamtalk/tests/bot_fsm_extra_tests.rs new file mode 100644 index 0000000..d6eacbc --- /dev/null +++ b/crates/teamtalk/tests/bot_fsm_extra_tests.rs @@ -0,0 +1,269 @@ +#![cfg(feature = "mock")] +//! Additional `DialogState` / `DialogFlow` / `DialogMachine` coverage that +//! pins down behaviour not exercised by `bot_fsm.rs`. + +use std::thread; +use std::time::Duration; + +use teamtalk::bot::{DialogFlow, DialogMachine, DialogState, DialogStatus, DialogTimeoutPolicy}; +use teamtalk::types::UserId; +use teamtalk::{MemoryStateStore, StateStore}; + +fn user() -> UserId { + UserId(42) +} + +#[test] +fn dialog_state_defaults_to_active_and_no_deadline() { + let state = DialogState::new("d", "s"); + assert!(state.is_active()); + assert!(!state.is_paused()); + assert!(!state.is_expired()); + assert_eq!(state.deadline_unix_ms, None); + assert!(state.metadata.is_empty()); +} + +#[test] +fn dialog_state_is_expired_at_past_deadline() { + let state = DialogState::new("d", "s").with_deadline_unix_ms(100); + assert!(state.is_expired_at(200)); + assert!(state.is_expired_at(100)); // boundary: deadline <= now is expired + assert!(!state.is_expired_at(50)); +} + +#[test] +fn dialog_state_default_timeout_policy_is_clear() { + let state = DialogState::new("d", "s"); + assert_eq!(state.timeout_policy(), DialogTimeoutPolicy::Clear); +} + +#[test] +fn dialog_state_with_timeout_policy_is_round_tripped_through_encode_decode() { + let state = DialogState::new("d", "s").with_timeout_policy(DialogTimeoutPolicy::Pause); + assert_eq!(state.timeout_policy(), DialogTimeoutPolicy::Pause); + let decoded = DialogState::decode(&state.encode()).expect("decode"); + assert_eq!(decoded.timeout_policy(), DialogTimeoutPolicy::Pause); +} + +#[test] +fn dialog_state_metadata_round_trips_including_order() { + let state = DialogState::new("d", "s").with_metadata(vec![("a", "1"), ("b", "2"), ("c", "3")]); + let decoded = DialogState::decode(&state.encode()).expect("decode"); + let decoded_meta: Vec<(String, String)> = decoded + .metadata + .iter() + .filter(|(k, _)| !k.starts_with("__")) + .cloned() + .collect(); + assert_eq!( + decoded_meta, + vec![ + ("a".into(), "1".into()), + ("b".into(), "2".into()), + ("c".into(), "3".into()), + ] + ); +} + +#[test] +fn dialog_state_set_metadata_replaces_existing_value_without_growing() { + let mut state = DialogState::new("d", "s"); + state.set_metadata("k", "v1"); + state.set_metadata("k", "v2"); + assert_eq!(state.metadata("k"), Some("v2")); + assert_eq!( + state.metadata.iter().filter(|(k, _)| k == "k").count(), + 1, + "replacing a metadata key must not create a second entry" + ); +} + +#[test] +fn dialog_state_remove_metadata_returns_value_and_removes_pair() { + let mut state = DialogState::new("d", "s"); + state.set_metadata("k", "v"); + assert_eq!(state.remove_metadata("k"), Some("v".into())); + assert_eq!(state.metadata("k"), None); + assert_eq!(state.remove_metadata("k"), None); +} + +#[test] +fn dialog_state_decode_legacy_pipe_format_is_accepted() { + let state = DialogState::decode("signup|step-1").expect("legacy format"); + assert_eq!(state.dialog, "signup"); + assert_eq!(state.step, "step-1"); + assert!(state.is_active()); + assert_eq!(state.deadline_unix_ms, None); +} + +#[test] +fn dialog_state_decode_rejects_malformed_input() { + assert!(DialogState::decode("not-a-real-encoding").is_none()); + assert!(DialogState::decode("|").is_none()); + assert!(DialogState::decode("dialog|").is_none()); + assert!(DialogState::decode("|step").is_none()); +} + +#[test] +fn dialog_flow_navigation_handles_first_last_and_missing_steps() { + let flow = DialogFlow::new("onboarding", "start") + .step("a") + .step("b") + .step("c"); + assert!(flow.is_start_step("start")); + assert!(!flow.is_start_step("a")); + assert!(flow.contains_step("start")); + assert!(flow.contains_step("a")); + assert!(!flow.contains_step("z")); + assert_eq!(flow.next_step("start"), Some("a")); + assert_eq!(flow.next_step("a"), Some("b")); + assert_eq!(flow.next_step("c"), None); + assert_eq!(flow.previous_step("a"), Some("start")); + assert_eq!(flow.previous_step("b"), Some("a")); + assert_eq!(flow.previous_step("start"), None); + assert!(flow.is_terminal_step("c")); + assert!(!flow.is_terminal_step("b")); +} + +#[test] +fn dialog_machine_assigns_unique_session_ids_per_start() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start(UserId(1), "flow", "s"); + let first = machine + .current(UserId(1)) + .unwrap() + .session_id() + .map(String::from); + machine.start(UserId(2), "flow", "s"); + let second = machine + .current(UserId(2)) + .unwrap() + .session_id() + .map(String::from); + assert!(first.is_some() && second.is_some()); + assert_ne!(first, second); +} + +#[test] +fn dialog_machine_advance_live_clears_paused_status() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start_state( + user(), + DialogState::new("d", "s1").with_status(DialogStatus::Paused), + ); + let advanced = machine.advance(user(), "s2").expect("advanced"); + assert_eq!(advanced.step, "s2"); + assert!( + advanced.is_active(), + "advancing must re-activate a paused dialog" + ); +} + +#[test] +fn dialog_machine_current_live_with_clear_policy_stops_expired_dialog() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start_state( + user(), + DialogState::new("d", "s") + .with_deadline_unix_ms(1) + .with_timeout_policy(DialogTimeoutPolicy::Clear), + ); + assert!(machine.current_live(user()).is_none()); + assert!( + machine.current(user()).is_none(), + "Clear policy must have removed the persisted state" + ); +} + +#[test] +fn dialog_machine_current_live_with_pause_policy_flips_to_paused() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start_state( + user(), + DialogState::new("d", "s") + .with_deadline_unix_ms(1) + .with_timeout_policy(DialogTimeoutPolicy::Pause), + ); + let live = machine.current_live(user()).expect("state is preserved"); + assert!(live.is_paused()); + assert_eq!(live.deadline_unix_ms, None); + // Subsequent calls should keep seeing the paused state. + assert!(machine.current(user()).unwrap().is_paused()); + assert!(machine.current_active(user()).is_none()); +} + +#[test] +fn dialog_machine_set_and_clear_timeout_update_deadline() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start(user(), "d", "s"); + let with_timeout = machine + .set_timeout(user(), Duration::from_secs(60)) + .unwrap(); + assert!(with_timeout.deadline_unix_ms.is_some()); + let cleared = machine.clear_timeout(user()).unwrap(); + assert_eq!(cleared.deadline_unix_ms, None); +} + +#[test] +fn dialog_machine_metadata_helpers_persist_across_reloads() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start(user(), "d", "s"); + machine.set_metadata(user(), "k", "v"); + assert_eq!(machine.metadata(user(), "k"), Some("v".into())); + let (state, removed) = machine.remove_metadata(user(), "k").unwrap(); + assert_eq!(removed, Some("v".into())); + assert!(state.metadata("k").is_none()); + assert_eq!(machine.metadata(user(), "k"), None); +} + +#[test] +fn dialog_machine_with_prefix_uses_custom_key_namespace() { + let mut store = MemoryStateStore::new(); + { + let mut machine = DialogMachine::with_prefix(&mut store, "svc:flow"); + machine.start(user(), "d", "s"); + } + let keys = store.keys("svc:flow:"); + assert_eq!( + keys.len(), + 1, + "prefix should namespace the dialog storage key" + ); + assert!(store.keys("bot:dialog").is_empty()); +} + +#[test] +fn dialog_machine_is_in_ignores_paused_state() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + machine.start_state( + user(), + DialogState::new("d", "s").with_status(DialogStatus::Paused), + ); + assert!(!machine.is_in(user(), "d", "s")); + machine.resume(user()); + assert!(machine.is_in(user(), "d", "s")); +} + +#[test] +fn dialog_machine_restart_flow_produces_fresh_session_id() { + let mut store = MemoryStateStore::new(); + let mut machine = DialogMachine::new(&mut store); + let flow = DialogFlow::new("wizard", "start").step("a").step("b"); + machine.start(user(), "wizard", "start"); + let before = machine + .current(user()) + .and_then(|s| s.session_id().map(String::from)); + // Ensure the subsequent session id is strictly different (generator counter). + thread::sleep(Duration::from_millis(2)); + let after_state = machine.restart_flow(user(), &flow); + assert_eq!(after_state.step, "start"); + let after = after_state.session_id().map(String::from); + assert!(before.is_some() && after.is_some() && before != after); +} diff --git a/crates/teamtalk/tests/bot_state_v2_extra_tests.rs b/crates/teamtalk/tests/bot_state_v2_extra_tests.rs new file mode 100644 index 0000000..626bbac --- /dev/null +++ b/crates/teamtalk/tests/bot_state_v2_extra_tests.rs @@ -0,0 +1,140 @@ +#![cfg(feature = "mock")] +//! Additional coverage for `MemoryStateStore` behaviour beyond the baseline +//! exercised in `bot_state_v2.rs`. + +use std::thread; +use std::time::Duration; + +use teamtalk::{MemoryStateStore, StateStore}; + +#[test] +fn set_overwrites_existing_value_without_growing_store() { + let mut store = MemoryStateStore::new(); + store.set("k".into(), "v1".into()); + store.set("k".into(), "v2".into()); + assert_eq!(store.get("k"), Some("v2".into())); + assert_eq!(store.keys("").len(), 1); +} + +#[test] +fn set_with_ttl_replaces_permanent_entry_with_expiring_one() { + let mut store = MemoryStateStore::new(); + store.set("k".into(), "permanent".into()); + store.set_with_ttl("k".into(), "ephemeral".into(), Duration::from_millis(20)); + thread::sleep(Duration::from_millis(40)); + assert_eq!(store.get("k"), None); +} + +#[test] +fn set_overwrites_expiring_entry_with_permanent_one() { + let mut store = MemoryStateStore::new(); + store.set_with_ttl("k".into(), "old".into(), Duration::from_millis(10)); + store.set("k".into(), "fresh".into()); + thread::sleep(Duration::from_millis(30)); + // The plain `set` must clear the TTL; value survives beyond original expiry. + assert_eq!(store.get("k"), Some("fresh".into())); +} + +#[test] +fn remove_evicts_expired_entry_even_though_it_returns_the_stale_value() { + // Documented behaviour: `remove` operates on the raw storage map and does + // not consult the TTL, so expired entries still return their stored value + // via `remove`. Confirm the entry is nevertheless evicted so subsequent + // lookups observe `None`. + let mut store = MemoryStateStore::new(); + store.set_with_ttl("k".into(), "v".into(), Duration::from_millis(10)); + thread::sleep(Duration::from_millis(30)); + assert_eq!(store.remove("k"), Some("v".into())); + assert_eq!(store.get("k"), None); + assert!(!store.exists("k")); +} + +#[test] +fn remove_prefix_returns_zero_when_nothing_matches() { + let mut store = MemoryStateStore::new(); + store.set("a".into(), "1".into()); + assert_eq!(store.remove_prefix("unused:"), 0); + assert_eq!(store.get("a"), Some("1".into())); +} + +#[test] +fn remove_prefix_empty_prefix_removes_every_key() { + let mut store = MemoryStateStore::new(); + store.set("a".into(), "1".into()); + store.set("b".into(), "2".into()); + store.set("c".into(), "3".into()); + let removed = store.remove_prefix(""); + assert_eq!(removed, 3); + assert!(store.keys("").is_empty()); +} + +#[test] +fn keys_with_empty_prefix_returns_all_non_expired_keys() { + let mut store = MemoryStateStore::new(); + store.set("foo".into(), "1".into()); + store.set("bar".into(), "2".into()); + let mut keys = store.keys(""); + keys.sort(); + assert_eq!(keys, vec!["bar", "foo"]); +} + +#[test] +fn exists_returns_false_for_expired_entry() { + let mut store = MemoryStateStore::new(); + store.set_with_ttl("k".into(), "v".into(), Duration::from_millis(10)); + thread::sleep(Duration::from_millis(30)); + assert!(!store.exists("k")); +} + +#[test] +fn get_many_preserves_requested_key_order() { + let mut store = MemoryStateStore::new(); + store.set("b".into(), "2".into()); + store.set("a".into(), "1".into()); + let result = store.get_many(&["a", "b", "c"]); + assert_eq!(result, vec![Some("1".into()), Some("2".into()), None]); +} + +#[test] +fn set_many_accepts_empty_batch() { + let mut store = MemoryStateStore::new(); + store.set_many(Vec::new()); + assert!(store.keys("").is_empty()); +} + +#[test] +fn set_many_overwrites_existing_values() { + let mut store = MemoryStateStore::new(); + store.set("a".into(), "before".into()); + store.set_many(vec![ + ("a".into(), "after".into()), + ("b".into(), "new".into()), + ]); + assert_eq!(store.get("a"), Some("after".into())); + assert_eq!(store.get("b"), Some("new".into())); +} + +#[test] +fn get_returns_none_for_missing_and_expired_entries() { + let mut store = MemoryStateStore::new(); + assert_eq!(store.get("missing"), None); + store.set_with_ttl("ephemeral".into(), "x".into(), Duration::from_millis(10)); + thread::sleep(Duration::from_millis(30)); + assert_eq!(store.get("ephemeral"), None); +} + +#[test] +fn remove_prefix_ignores_expired_entries_in_its_count() { + let mut store = MemoryStateStore::new(); + store.set("p:a".into(), "1".into()); + store.set_with_ttl("p:b".into(), "2".into(), Duration::from_millis(10)); + store.set("other".into(), "3".into()); + thread::sleep(Duration::from_millis(30)); + let removed = store.remove_prefix("p:"); + assert!( + removed == 1 || removed == 2, + "expected remove_prefix to report 1 or 2 (implementation-defined whether it counts the already-expired entry), got {removed}" + ); + assert!(store.keys("p:").is_empty()); + assert_eq!(store.get("other"), Some("3".into())); +} diff --git a/crates/teamtalk/tests/dispatcher_extra_tests.rs b/crates/teamtalk/tests/dispatcher_extra_tests.rs new file mode 100644 index 0000000..1f13c56 --- /dev/null +++ b/crates/teamtalk/tests/dispatcher_extra_tests.rs @@ -0,0 +1,246 @@ +#![cfg(feature = "mock")] +//! Additional high-level `Dispatcher` behaviour tests. Complements +//! `dispatch_tests.rs` and `indexed_dispatch_tests.rs` by exercising the +//! public builder surface, shortcut handlers and flow propagation. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use teamtalk::Event; +use teamtalk::dispatch::{DispatchFlow, Dispatcher}; +use teamtalk::mock::{MockClient, MockUserBuilder}; +use teamtalk::types::UserId; + +fn bump( + counter: &Arc, +) -> impl FnMut(teamtalk::dispatch::EventContext<'_>) -> DispatchFlow + Send + 'static + use<> { + let c = Arc::clone(counter); + move |_| { + c.fetch_add(1, Ordering::SeqCst); + DispatchFlow::Continue + } +} + +#[test] +fn dispatcher_step_returns_none_flow_when_queue_empty() { + let mock = MockClient::new(); + let mut dispatcher = Dispatcher::new(mock).on_any(|_| DispatchFlow::Continue); + // No events queued: step should report Continue and not trigger any handler. + assert!(matches!(dispatcher.step(0), DispatchFlow::Continue)); +} + +#[test] +fn dispatcher_on_event_filters_by_event_variant() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + mock.push_event(Event::ConnectFailed); + mock.push_event(Event::ConnectionLost); + + let success = Arc::new(AtomicUsize::new(0)); + let lost = Arc::new(AtomicUsize::new(0)); + let mut dispatcher = Dispatcher::new(mock) + .on_event(Event::ConnectSuccess, bump(&success)) + .on_event(Event::ConnectionLost, bump(&lost)); + + for _ in 0..3 { + dispatcher.step(0); + } + assert_eq!(success.load(Ordering::SeqCst), 1); + assert_eq!(lost.load(Ordering::SeqCst), 1); +} + +#[test] +fn dispatcher_on_any_fires_for_every_event_type() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + mock.push_event(Event::ConnectFailed); + mock.push_event(Event::ConnectionLost); + + let total = Arc::new(AtomicUsize::new(0)); + let mut dispatcher = Dispatcher::new(mock).on_any(bump(&total)); + for _ in 0..3 { + dispatcher.step(0); + } + assert_eq!(total.load(Ordering::SeqCst), 3); +} + +#[test] +fn dispatcher_stop_flow_from_any_handler_stops_step() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + let mut dispatcher = Dispatcher::new(mock).on_any(|_| DispatchFlow::Stop); + assert!(matches!(dispatcher.step(0), DispatchFlow::Stop)); +} + +#[test] +fn dispatcher_connect_shortcut_handlers_fire_for_expected_events() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + mock.push_event(Event::ConnectionLost); + mock.push_event(Event::ConnectFailed); + mock.push_event(Event::CmdError); + + let ok = Arc::new(AtomicUsize::new(0)); + let lost = Arc::new(AtomicUsize::new(0)); + let failed = Arc::new(AtomicUsize::new(0)); + let cmd_err = Arc::new(AtomicUsize::new(0)); + + let mut dispatcher = Dispatcher::new(mock) + .on_connect_success(bump(&ok)) + .on_connection_lost(bump(&lost)) + .on_connect_failed(bump(&failed)) + .on_command_error(bump(&cmd_err)); + + for _ in 0..4 { + dispatcher.step(0); + } + + assert_eq!(ok.load(Ordering::SeqCst), 1); + assert_eq!(lost.load(Ordering::SeqCst), 1); + assert_eq!(failed.load(Ordering::SeqCst), 1); + assert_eq!(cmd_err.load(Ordering::SeqCst), 1); +} + +#[test] +fn dispatcher_user_joined_shortcut_receives_user_payload() { + let mut mock = MockClient::new(); + mock.push_user_joined( + MockUserBuilder::new(UserId(7)) + .username("alice") + .nickname("a"), + ); + let seen = Arc::new(AtomicUsize::new(0)); + let captured_id = Arc::new(AtomicUsize::new(0)); + + let seen_c = Arc::clone(&seen); + let captured_c = Arc::clone(&captured_id); + let mut dispatcher = Dispatcher::new(mock).on_user_joined(move |ctx| { + seen_c.fetch_add(1, Ordering::SeqCst); + if let Some(user) = ctx.message().user() { + captured_c.store(user.id.0 as usize, Ordering::SeqCst); + } + DispatchFlow::Continue + }); + dispatcher.step(0); + + assert_eq!(seen.load(Ordering::SeqCst), 1); + assert_eq!(captured_id.load(Ordering::SeqCst), 7); +} + +#[test] +fn dispatcher_multiple_specific_handlers_for_same_event_all_fire_in_order() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + + let order = Arc::new(std::sync::Mutex::new(Vec::::new())); + let o1 = Arc::clone(&order); + let o2 = Arc::clone(&order); + let o3 = Arc::clone(&order); + + let mut dispatcher = Dispatcher::new(mock) + .on_event(Event::ConnectSuccess, move |_| { + o1.lock().unwrap().push(1); + DispatchFlow::Continue + }) + .on_event(Event::ConnectSuccess, move |_| { + o2.lock().unwrap().push(2); + DispatchFlow::Continue + }) + .on_event(Event::ConnectSuccess, move |_| { + o3.lock().unwrap().push(3); + DispatchFlow::Continue + }); + dispatcher.step(0); + + assert_eq!(order.lock().unwrap().as_slice(), &[1, 2, 3]); +} + +#[test] +fn dispatcher_specific_and_wildcard_interleave_in_insertion_order() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + + let order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new())); + let a = Arc::clone(&order); + let b = Arc::clone(&order); + let c = Arc::clone(&order); + + let mut dispatcher = Dispatcher::new(mock) + .on_event(Event::ConnectSuccess, move |_| { + a.lock().unwrap().push("spec"); + DispatchFlow::Continue + }) + .on_any(move |_| { + b.lock().unwrap().push("any1"); + DispatchFlow::Continue + }) + .on_event(Event::ConnectSuccess, move |_| { + c.lock().unwrap().push("spec2"); + DispatchFlow::Continue + }); + dispatcher.step(0); + + assert_eq!(order.lock().unwrap().as_slice(), &["spec", "any1", "spec2"]); +} + +#[test] +fn dispatcher_stop_from_first_handler_still_surfaces_stop_and_lets_siblings_run() { + // Dispatcher semantics: all matching handlers fire for a single event, but + // a `Stop` from any of them causes `step()` to return `Stop` so the caller + // can exit the run loop after the current event finishes dispatching. + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + + let later = Arc::new(AtomicUsize::new(0)); + let later_c = Arc::clone(&later); + let mut dispatcher = Dispatcher::new(mock) + .on_event(Event::ConnectSuccess, |_| DispatchFlow::Stop) + .on_event(Event::ConnectSuccess, move |_| { + later_c.fetch_add(1, Ordering::SeqCst); + DispatchFlow::Continue + }); + assert!(matches!(dispatcher.step(0), DispatchFlow::Stop)); + assert_eq!(later.load(Ordering::SeqCst), 1); +} + +#[test] +fn dispatcher_new_starts_with_no_handlers_and_drains_events_silently() { + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + mock.push_event(Event::ConnectFailed); + let mut dispatcher = Dispatcher::new(mock); + for _ in 0..2 { + assert!(matches!(dispatcher.step(0), DispatchFlow::Continue)); + } +} + +#[test] +fn dispatcher_text_message_shortcut_fires_only_for_text_messages() { + use teamtalk::client::ffi; + use teamtalk::mock::MockMessage; + use teamtalk::types::ChannelId; + + let mut mock = MockClient::new(); + mock.push_event(Event::ConnectSuccess); + let msg = MockMessage::text( + ffi::TextMsgType::MSGTYPE_USER, + UserId(1), + UserId(2), + ChannelId(3), + "alice", + "hi", + ); + mock.push_text_message(msg); + mock.push_event(Event::ConnectionLost); + + let text_hits = Arc::new(AtomicUsize::new(0)); + let hits = Arc::clone(&text_hits); + let mut dispatcher = Dispatcher::new(mock).on_text_message(move |_| { + hits.fetch_add(1, Ordering::SeqCst); + DispatchFlow::Continue + }); + for _ in 0..3 { + dispatcher.step(0); + } + assert_eq!(text_hits.load(Ordering::SeqCst), 1); +} diff --git a/crates/teamtalk/tests/events_error_extra_tests.rs b/crates/teamtalk/tests/events_error_extra_tests.rs new file mode 100644 index 0000000..e361b77 --- /dev/null +++ b/crates/teamtalk/tests/events_error_extra_tests.rs @@ -0,0 +1,156 @@ +//! Additional coverage for `Event`, `Error`, `FfiError` and `ConnectionState` +//! helpers that are not exercised elsewhere. + +use std::time::Duration; +use teamtalk::events::{ConnectionState, Error, Event, FfiError}; +use teamtalk::types::ChannelId; + +#[test] +fn event_is_reconnect_needed_matches_documented_variants() { + assert!(Event::ConnectionLost.is_reconnect_needed()); + assert!(Event::ConnectFailed.is_reconnect_needed()); + assert!(Event::ConnectCryptError.is_reconnect_needed()); + assert!(!Event::ConnectSuccess.is_reconnect_needed()); + assert!(!Event::TextMessage.is_reconnect_needed()); + assert!(!Event::UserJoined.is_reconnect_needed()); +} + +#[test] +fn event_is_reconnect_needed_with_accepts_extra_by_discriminant() { + let extra = [Event::MySelfKicked]; + assert!(Event::MySelfKicked.is_reconnect_needed_with(&extra)); + assert!(Event::ConnectionLost.is_reconnect_needed_with(&extra)); + assert!(!Event::UserLoggedIn.is_reconnect_needed_with(&extra)); +} + +#[test] +fn event_is_reconnect_needed_with_ignores_payload_differences() { + // Parametric variants compare by discriminant regardless of payload. + let extra = [Event::BeforeReconnect { + attempt: 1, + delay: Duration::from_secs(1), + }]; + let probe = Event::BeforeReconnect { + attempt: 99, + delay: Duration::from_millis(3), + }; + assert!(probe.is_reconnect_needed_with(&extra)); +} + +#[test] +fn event_is_reconnect_needed_with_handles_empty_extras_correctly() { + assert!(!Event::TextMessage.is_reconnect_needed_with(&[])); + assert!(Event::ConnectionLost.is_reconnect_needed_with(&[])); +} + +#[test] +fn connection_state_default_is_idle() { + assert_eq!(ConnectionState::default(), ConnectionState::Idle); +} + +#[test] +fn connection_state_joining_and_joined_are_distinct_for_different_channels() { + let a = ConnectionState::Joining(ChannelId(1)); + let b = ConnectionState::Joining(ChannelId(2)); + assert_ne!(a, b); +} + +#[test] +fn error_display_includes_command_code_and_message() { + let err = Error::CommandFailed { + code: 42, + message: "nope".to_string(), + }; + let rendered = err.to_string(); + assert!(rendered.contains("42")); + assert!(rendered.contains("nope")); + assert!(rendered.starts_with("Command failed")); +} + +#[test] +fn error_display_covers_stateless_variants() { + assert_eq!(Error::InitFailed.to_string(), "Init failed"); + assert_eq!(Error::ConnectFailed.to_string(), "Connection failed"); + assert_eq!(Error::AuthFailed.to_string(), "Auth failed"); + assert_eq!(Error::InvalidParam.to_string(), "Invalid parameter"); + assert_eq!( + Error::MissingLoginParams.to_string(), + "Missing login parameters" + ); + assert_eq!( + Error::MissingReconnectParams.to_string(), + "Missing reconnect parameters" + ); + assert_eq!(Error::Timeout.to_string(), "Operation timed out"); +} + +#[test] +fn error_io_and_client_variants_carry_messages() { + let io = Error::IoError { + message: "read fail".into(), + }; + assert!(io.to_string().contains("read fail")); + let sdk = Error::ClientError { + code: 9, + message: "inner".into(), + }; + assert!(sdk.to_string().contains("9")); + assert!(sdk.to_string().contains("inner")); +} + +#[test] +fn ffi_error_display_differentiates_variants() { + assert_eq!(FfiError::BoolFalse.to_string(), "operation returned false"); + assert_eq!(FfiError::NullPointer.to_string(), "null pointer returned"); + let sdk = FfiError::SdkError { + code: 7, + message: "boom".into(), + }; + assert!(sdk.to_string().contains("7")); + assert!(sdk.to_string().contains("boom")); +} + +#[test] +fn ffi_error_converts_to_error_via_from_impl() { + let ffi = FfiError::BoolFalse; + let err: Error = ffi.into(); + assert!(matches!(err, Error::Ffi(FfiError::BoolFalse))); + // Display cascades through the `#[from]` variant. + let rendered = err.to_string(); + assert!(rendered.starts_with("FFI error")); + assert!(rendered.contains("operation returned false")); +} + +#[test] +fn ffi_error_sdk_variant_roundtrips_payload_via_clone() { + let original = FfiError::SdkError { + code: 12, + message: "hi".into(), + }; + let cloned = original.clone(); + match cloned { + FfiError::SdkError { code, message } => { + assert_eq!(code, 12); + assert_eq!(message, "hi"); + } + _ => panic!("expected SdkError"), + } +} + +#[test] +fn event_derives_copy_and_eq_for_stateless_variants() { + let a = Event::ConnectSuccess; + let b = a; // Copy + assert_eq!(a, b); + assert_ne!(Event::ConnectSuccess, Event::ConnectFailed); +} + +#[test] +fn event_unknown_carries_ffi_client_event() { + use teamtalk::client::ffi; + let e = Event::Unknown(ffi::ClientEvent::CLIENTEVENT_NONE); + match e { + Event::Unknown(code) => assert_eq!(code, ffi::ClientEvent::CLIENTEVENT_NONE), + _ => panic!("expected Unknown"), + } +} diff --git a/crates/teamtalk/tests/scheduler_tests.rs b/crates/teamtalk/tests/scheduler_tests.rs new file mode 100644 index 0000000..dbc7d38 --- /dev/null +++ b/crates/teamtalk/tests/scheduler_tests.rs @@ -0,0 +1,197 @@ +#![cfg(feature = "mock")] +//! Coverage for `bot::Scheduler` registration, enable/disable, removal, +//! tick delivery and `one_shot` vs recurring semantics. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::Duration; + +use teamtalk::MemoryStateStore; +use teamtalk::bot::{JobErrorPolicy, Scheduler}; +use teamtalk::client::Client; +use teamtalk::client::backend::MockBackend; + +fn client() -> Client { + let backend = Arc::new(MockBackend::new()); + Client::with_backend(backend).expect("mock client") +} + +fn store() -> MemoryStateStore { + MemoryStateStore::new() +} + +#[test] +fn scheduler_new_is_empty_and_next_run_delay_is_bounded() { + let scheduler = Scheduler::new(); + assert!(scheduler.job_names().is_empty()); + // No jobs → fall back to 1h sentinel so callers can idle. + assert!(scheduler.next_run_delay() >= Duration::from_secs(60)); +} + +#[test] +fn every_named_registers_job_and_lists_it() { + let mut scheduler = Scheduler::new(); + scheduler.every_named( + "heartbeat", + Duration::from_millis(50), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + assert_eq!(scheduler.job_names(), vec!["heartbeat"]); + assert_eq!(scheduler.is_enabled("heartbeat"), Some(true)); +} + +#[test] +fn every_generates_unique_job_names() { + let mut scheduler = Scheduler::new(); + scheduler.every( + Duration::from_millis(50), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + scheduler.every( + Duration::from_millis(50), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + let names = scheduler.job_names(); + assert_eq!(names.len(), 2); + assert_ne!(names[0], names[1]); +} + +#[test] +fn remove_returns_true_only_when_job_existed() { + let mut scheduler = Scheduler::new(); + scheduler.every_named( + "a", + Duration::from_millis(50), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + assert!(scheduler.remove("a")); + assert!(!scheduler.remove("a")); + assert!(scheduler.job_names().is_empty()); +} + +#[test] +fn set_enabled_toggles_job_status() { + let mut scheduler = Scheduler::new(); + scheduler.every_named( + "x", + Duration::from_millis(50), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + scheduler.set_enabled("x", false); + assert_eq!(scheduler.is_enabled("x"), Some(false)); + scheduler.set_enabled("x", true); + assert_eq!(scheduler.is_enabled("x"), Some(true)); +} + +#[test] +fn set_enabled_on_missing_job_is_noop() { + let mut scheduler = Scheduler::new(); + scheduler.set_enabled("ghost", false); + assert_eq!(scheduler.is_enabled("ghost"), None); +} + +#[test] +fn is_enabled_returns_none_for_missing_job() { + let scheduler = Scheduler::new(); + assert_eq!(scheduler.is_enabled("ghost"), None); +} + +#[test] +fn recurring_job_runs_multiple_times_across_ticks() { + let mut scheduler = Scheduler::new(); + let counter = Arc::new(AtomicUsize::new(0)); + let c = Arc::clone(&counter); + scheduler.every_named( + "tick", + Duration::from_millis(10), + JobErrorPolicy::KeepRunning, + move |_, _| { + c.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ); + + let client = client(); + let mut state = store(); + for _ in 0..3 { + thread::sleep(Duration::from_millis(15)); + scheduler.tick(&client, &mut state).unwrap(); + } + assert!( + counter.load(Ordering::SeqCst) >= 3, + "recurring job should have fired at least 3 times" + ); +} + +#[test] +fn after_job_is_one_shot_and_disables_itself() { + let mut scheduler = Scheduler::new(); + let counter = Arc::new(AtomicUsize::new(0)); + let c = Arc::clone(&counter); + scheduler.after("once", Duration::from_millis(10), move |_, _| { + c.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + + let client = client(); + let mut state = store(); + for _ in 0..5 { + thread::sleep(Duration::from_millis(15)); + scheduler.tick(&client, &mut state).unwrap(); + } + assert_eq!(counter.load(Ordering::SeqCst), 1); + // One-shot jobs are fully removed from the scheduler once they finish. + assert_eq!(scheduler.is_enabled("once"), None); + assert!(scheduler.job_names().is_empty()); +} + +#[test] +fn disabled_job_does_not_run_on_tick() { + let mut scheduler = Scheduler::new(); + let counter = Arc::new(AtomicUsize::new(0)); + let c = Arc::clone(&counter); + scheduler.every_named( + "x", + Duration::from_millis(10), + JobErrorPolicy::KeepRunning, + move |_, _| { + c.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ); + scheduler.set_enabled("x", false); + + let client = client(); + let mut state = store(); + for _ in 0..3 { + thread::sleep(Duration::from_millis(15)); + scheduler.tick(&client, &mut state).unwrap(); + } + assert_eq!(counter.load(Ordering::SeqCst), 0); +} + +#[test] +fn next_run_delay_only_considers_enabled_jobs() { + let mut scheduler = Scheduler::new(); + scheduler.every_named( + "a", + Duration::from_millis(50), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + scheduler.every_named( + "b", + Duration::from_secs(3600), + JobErrorPolicy::KeepRunning, + |_, _| Ok(()), + ); + scheduler.set_enabled("a", false); + // Only `b` remains enabled, so the soonest delay should be near 3600s. + assert!(scheduler.next_run_delay() >= Duration::from_secs(60)); +}