From fbfd57604f1f216e09461d2197d7875ce74faa98 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sat, 25 May 2024 13:37:31 -0400 Subject: [PATCH] Refactor TUI tests significantly Add two new types, TestHarness and TestComponent, that make it much easier to test components. This should make TUI test code much simpler and more consistent, and it's much easier to right *correct* test code now. --- src/collection.rs | 2 +- src/collection/insomnia.rs | 2 +- src/collection/models.rs | 83 +++++++ src/collection/recipe_tree.rs | 2 +- src/db.rs | 2 +- src/http.rs | 2 +- src/http/content_type.rs | 2 +- src/http/query.rs | 2 +- src/http/record.rs | 77 ++++++- src/template.rs | 20 +- src/template/parse.rs | 2 +- src/test_util.rs | 277 +----------------------- src/tui.rs | 2 + src/tui/context.rs | 27 ++- src/tui/input.rs | 17 +- src/tui/test_util.rs | 97 +++++++++ src/tui/util.rs | 13 +- src/tui/view.rs | 25 ++- src/tui/view/common/template_preview.rs | 7 +- src/tui/view/common/text_box.rs | 158 +++++--------- src/tui/view/component/internal.rs | 124 ++++------- src/tui/view/component/primary.rs | 98 ++++----- src/tui/view/component/record_body.rs | 94 ++++---- src/tui/view/component/response_view.rs | 67 +++--- src/tui/view/component/root.rs | 93 +++----- src/tui/view/context.rs | 70 +----- src/tui/view/draw.rs | 10 +- src/tui/view/state/persistence.rs | 5 +- src/tui/view/state/request_store.rs | 36 ++- src/tui/view/test_util.rs | 228 +++++++++++++++++++ 30 files changed, 856 insertions(+), 788 deletions(-) create mode 100644 src/tui/test_util.rs create mode 100644 src/tui/view/test_util.rs diff --git a/src/collection.rs b/src/collection.rs index 4f31433d..1c9ff544 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -177,7 +177,7 @@ async fn load_collection(path: PathBuf) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::test_util::{assert_err, temp_dir, TempDir}; use rstest::rstest; use std::fs::File; diff --git a/src/collection/insomnia.rs b/src/collection/insomnia.rs index e917511e..8eca6d01 100644 --- a/src/collection/insomnia.rs +++ b/src/collection/insomnia.rs @@ -461,7 +461,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{collection::CollectionFile, test_util::*}; + use crate::{collection::CollectionFile, test_util::test_data_dir}; use indexmap::indexmap; use pretty_assertions::assert_eq; use rstest::rstest; diff --git a/src/collection/models.rs b/src/collection/models.rs index edc063e3..da4da994 100644 --- a/src/collection/models.rs +++ b/src/collection/models.rs @@ -327,6 +327,33 @@ impl Collection { } } +#[cfg(test)] +impl crate::test_util::Factory for Collection { + fn factory(_: ()) -> Self { + let recipe = Recipe::factory(()); + let profile = Profile::factory(()); + Collection { + recipes: indexmap::indexmap! {recipe.id.clone() => recipe}.into(), + profiles: indexmap::indexmap! {profile.id.clone() => profile}, + ..Collection::default() + } + } +} + +#[cfg(test)] +impl crate::test_util::Factory for ProfileId { + fn factory(_: ()) -> Self { + uuid::Uuid::new_v4().to_string().into() + } +} + +#[cfg(test)] +impl crate::test_util::Factory for RecipeId { + fn factory(_: ()) -> Self { + uuid::Uuid::new_v4().to_string().into() + } +} + impl Profile { /// Get a presentable name for this profile pub fn name(&self) -> &str { @@ -334,6 +361,17 @@ impl Profile { } } +#[cfg(test)] +impl crate::test_util::Factory for Profile { + fn factory(_: ()) -> Self { + Self { + id: "profile1".into(), + name: None, + data: IndexMap::new(), + } + } +} + impl Folder { /// Get a presentable name for this folder pub fn name(&self) -> &str { @@ -341,6 +379,17 @@ impl Folder { } } +#[cfg(test)] +impl crate::test_util::Factory for Folder { + fn factory(_: ()) -> Self { + Self { + id: "folder1".into(), + name: None, + children: IndexMap::new(), + } + } +} + impl Recipe { /// Get a presentable name for this recipe pub fn name(&self) -> &str { @@ -348,6 +397,22 @@ impl Recipe { } } +#[cfg(test)] +impl crate::test_util::Factory for Recipe { + fn factory(_: ()) -> Self { + Self { + id: "recipe1".into(), + name: None, + method: Method::Get, + url: "http://localhost/url".into(), + body: None, + authentication: None, + query: IndexMap::new(), + headers: IndexMap::new(), + } + } +} + /// For deserialization impl TryFrom for Method { type Error = anyhow::Error; @@ -369,3 +434,21 @@ impl From for String { method.to_string() } } + +#[cfg(test)] +impl crate::test_util::Factory for Chain { + fn factory(_: ()) -> Self { + Self { + id: "chain1".into(), + source: ChainSource::Request { + recipe: "recipe1".into(), + trigger: Default::default(), + section: Default::default(), + }, + sensitive: false, + selector: None, + content_type: None, + trim: ChainOutputTrim::default(), + } + } +} diff --git a/src/collection/recipe_tree.rs b/src/collection/recipe_tree.rs index fea8047b..fb0f43fa 100644 --- a/src/collection/recipe_tree.rs +++ b/src/collection/recipe_tree.rs @@ -230,7 +230,7 @@ impl From<&Vec<&RecipeId>> for RecipeLookupKey { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::test_util::{assert_err, Factory}; use indexmap::indexmap; use itertools::Itertools; use rstest::{fixture, rstest}; diff --git a/src/db.rs b/src/db.rs index 41300e59..18d8f3c3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -665,7 +665,7 @@ impl<'a, 'b> TryFrom<&'a Row<'b>> for RequestRecordSummary { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::test_util::Factory; use itertools::Itertools; use std::collections::HashMap; diff --git a/src/http.rs b/src/http.rs index 8409b53c..374c8ad3 100644 --- a/src/http.rs +++ b/src/http.rs @@ -568,7 +568,7 @@ mod tests { use super::*; use crate::{ collection::{Authentication, Collection, Profile}, - test_util::*, + test_util::{header_map, Factory}, }; use indexmap::indexmap; use pretty_assertions::assert_eq; diff --git a/src/http/content_type.rs b/src/http/content_type.rs index a66a404e..d17cb816 100644 --- a/src/http/content_type.rs +++ b/src/http/content_type.rs @@ -172,7 +172,7 @@ impl ContentType { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::test_util::{assert_err, Factory}; use reqwest::header::{ HeaderMap, HeaderValue, InvalidHeaderValue, CONTENT_TYPE, }; diff --git a/src/http/query.rs b/src/http/query.rs index c8d009b1..f833e6b0 100644 --- a/src/http/query.rs +++ b/src/http/query.rs @@ -75,7 +75,7 @@ impl Query { #[cfg(test)] mod tests { use super::*; - use crate::{http::Json, test_util::*}; + use crate::{http::Json, test_util::assert_err}; use rstest::rstest; use serde_json::json; diff --git a/src/http/record.rs b/src/http/record.rs index 0920bd84..df359c7e 100644 --- a/src/http/record.rs +++ b/src/http/record.rs @@ -157,6 +157,81 @@ impl Request { } } +#[cfg(test)] +impl crate::test_util::Factory for Request { + fn factory(_: ()) -> Self { + Self { + id: RequestId::new(), + profile_id: None, + recipe_id: "recipe1".into(), + method: reqwest::Method::GET, + url: "http://localhost/url".parse().unwrap(), + headers: HeaderMap::new(), + body: None, + } + } +} + +/// Customize profile and recipe ID +#[cfg(test)] +impl crate::test_util::Factory<(Option, RecipeId)> for Request { + fn factory((profile_id, recipe_id): (Option, RecipeId)) -> Self { + Self { + id: RequestId::new(), + profile_id, + recipe_id, + method: reqwest::Method::GET, + url: "http://localhost/url".parse().unwrap(), + headers: HeaderMap::new(), + body: None, + } + } +} + +#[cfg(test)] +impl crate::test_util::Factory for Response { + fn factory(_: ()) -> Self { + Self { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: Body::default(), + } + } +} + +#[cfg(test)] +impl crate::test_util::Factory for RequestRecord { + fn factory(_: ()) -> Self { + let request = Request::factory(()); + let response = Response::factory(()); + Self { + id: request.id, + request: request.into(), + response: response.into(), + start_time: Utc::now(), + end_time: Utc::now(), + } + } +} + +/// Customize profile and recipe ID +#[cfg(test)] +impl crate::test_util::Factory<(Option, RecipeId)> + for RequestRecord +{ + fn factory(params: (Option, RecipeId)) -> Self { + let request = Request::factory(params); + let response = Response::factory(()); + Self { + id: request.id, + request: request.into(), + response: response.into(), + start_time: Utc::now(), + end_time: Utc::now(), + } + } +} + /// A resolved HTTP response, with all content loaded and ready to be displayed /// to the user. A simpler alternative to [reqwest::Response], because there's /// no way to access all resolved data on that type at once. Resolving the @@ -395,7 +470,7 @@ impl PartialEq for RequestError { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::test_util::{header_map, Factory}; use indexmap::indexmap; use rstest::rstest; use serde_json::json; diff --git a/src/template.rs b/src/template.rs index 19de9d6b..0dd89f55 100644 --- a/src/template.rs +++ b/src/template.rs @@ -175,6 +175,22 @@ impl TemplateKey { } } +#[cfg(test)] +impl crate::test_util::Factory for TemplateContext { + fn factory(_: ()) -> Self { + use crate::test_util::TestPrompter; + Self { + collection: Collection::default(), + selected_profile: None, + http_engine: None, + database: CollectionDatabase::factory(()), + overrides: IndexMap::new(), + prompter: Box::::default(), + recursion_count: 0.into(), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -185,7 +201,9 @@ mod tests { }, config::Config, http::{ContentType, Request, RequestRecord, Response}, - test_util::*, + test_util::{ + assert_err, header_map, temp_dir, Factory, TempDir, TestPrompter, + }, }; use chrono::Utc; use indexmap::indexmap; diff --git a/src/template/parse.rs b/src/template/parse.rs index 4f470317..56b38c95 100644 --- a/src/template/parse.rs +++ b/src/template/parse.rs @@ -151,7 +151,7 @@ fn take_until_or_eof<'a>( #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::test_util::assert_err; use itertools::Itertools; use rstest::rstest; diff --git a/src/test_util.rs b/src/test_util.rs index af78e314..09d94b0b 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -1,32 +1,19 @@ +//! General test utilities, that apply to all parts of the program + use crate::{ - collection::{ - self, Chain, ChainOutputTrim, ChainSource, Collection, Folder, Profile, - ProfileId, Recipe, RecipeId, RecipeNode, RecipeTree, - }, - config::Config, - db::CollectionDatabase, - http::{Body, Request, RequestId, RequestRecord, Response}, - template::{Prompt, Prompter, Template, TemplateContext}, - tui::{ - context::TuiContext, - message::{Message, MessageSender}, - }, + collection::{ProfileId, Recipe, RecipeId, RecipeNode, RecipeTree}, + template::{Prompt, Prompter, Template}, util::ResultExt, }; use anyhow::Context; -use chrono::Utc; use derive_more::Deref; -use indexmap::{indexmap, IndexMap}; -use ratatui::{backend::TestBackend, Terminal}; -use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue}, - StatusCode, -}; +use indexmap::IndexMap; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use rstest::fixture; use std::{ env, fs, path::{Path, PathBuf}, }; -use tokio::sync::{mpsc, mpsc::UnboundedReceiver}; use uuid::Uuid; /// Test-only trait to build a placeholder instance of a struct. This is similar @@ -42,207 +29,12 @@ pub trait Factory { fn factory(param: Param) -> Self; } -impl Factory for Collection { - fn factory(_: ()) -> Self { - let recipe = Recipe::factory(()); - let profile = Profile::factory(()); - Collection { - recipes: indexmap! {recipe.id.clone() => recipe}.into(), - profiles: indexmap! {profile.id.clone() => profile}, - ..Collection::default() - } - } -} - -impl Factory for ProfileId { - fn factory(_: ()) -> Self { - Uuid::new_v4().to_string().into() - } -} - -impl Factory for RecipeId { - fn factory(_: ()) -> Self { - Uuid::new_v4().to_string().into() - } -} - -impl Factory for Profile { - fn factory(_: ()) -> Self { - Self { - id: "profile1".into(), - name: None, - data: IndexMap::new(), - } - } -} - -impl Factory for Folder { - fn factory(_: ()) -> Self { - Self { - id: "folder1".into(), - name: None, - children: IndexMap::new(), - } - } -} - -impl Factory for Recipe { - fn factory(_: ()) -> Self { - Self { - id: "recipe1".into(), - name: None, - method: collection::Method::Get, - url: "http://localhost/url".into(), - body: None, - authentication: None, - query: IndexMap::new(), - headers: IndexMap::new(), - } - } -} - -impl Factory for Chain { - fn factory(_: ()) -> Self { - Self { - id: "chain1".into(), - source: ChainSource::Request { - recipe: "recipe1".into(), - trigger: Default::default(), - section: Default::default(), - }, - sensitive: false, - selector: None, - content_type: None, - trim: ChainOutputTrim::default(), - } - } -} - -impl Factory for Request { - fn factory(_: ()) -> Self { - Self { - id: RequestId::new(), - profile_id: None, - recipe_id: "recipe1".into(), - method: reqwest::Method::GET, - url: "http://localhost/url".parse().unwrap(), - headers: HeaderMap::new(), - body: None, - } - } -} - -/// Customize profile and recipe ID -impl Factory<(Option, RecipeId)> for Request { - fn factory((profile_id, recipe_id): (Option, RecipeId)) -> Self { - Self { - id: RequestId::new(), - profile_id, - recipe_id, - method: reqwest::Method::GET, - url: "http://localhost/url".parse().unwrap(), - headers: HeaderMap::new(), - body: None, - } - } -} - -impl Factory for Response { - fn factory(_: ()) -> Self { - Self { - status: StatusCode::OK, - headers: HeaderMap::new(), - body: Body::default(), - } - } -} - -impl Factory for RequestRecord { - fn factory(_: ()) -> Self { - let request = Request::factory(()); - let response = Response::factory(()); - Self { - id: request.id, - request: request.into(), - response: response.into(), - start_time: Utc::now(), - end_time: Utc::now(), - } - } -} - -/// Customize profile and recipe ID -impl Factory<(Option, RecipeId)> for RequestRecord { - fn factory(params: (Option, RecipeId)) -> Self { - let request = Request::factory(params); - let response = Response::factory(()); - Self { - id: request.id, - request: request.into(), - response: response.into(), - start_time: Utc::now(), - end_time: Utc::now(), - } - } -} - -impl Factory for TemplateContext { - fn factory(_: ()) -> Self { - Self { - collection: Collection::default(), - selected_profile: None, - http_engine: None, - database: CollectionDatabase::factory(()), - overrides: IndexMap::new(), - prompter: Box::::default(), - recursion_count: 0.into(), - } - } -} - /// Directory containing static test data #[fixture] pub fn test_data_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data") } -/// Create a terminal instance for testing -#[fixture] -pub fn terminal( - terminal_width: u16, - terminal_height: u16, -) -> Terminal { - let backend = TestBackend::new(terminal_width, terminal_height); - Terminal::new(backend).unwrap() -} - -/// For injection to [terminal] fixture -#[fixture] -fn terminal_width() -> u16 { - 40 -} - -/// For injection to [terminal] fixture -#[fixture] -fn terminal_height() -> u16 { - 20 -} - -/// Create an in-memory database for a collection -#[fixture] -pub fn database() -> CollectionDatabase { - CollectionDatabase::factory(()) -} - -/// Test fixture for using TUI context. The context is a global read-only var, -/// so this will initialize it once for *all tests*. -#[fixture] -#[once] -pub fn tui_context() -> &'static TuiContext { - TuiContext::init(Config::default()); - TuiContext::get() -} - /// Create a new temporary folder. This will include a random subfolder to /// guarantee uniqueness for this test. #[fixture] @@ -274,48 +66,6 @@ impl Drop for TempDir { } } -#[fixture] -pub fn messages() -> MessageQueue { - let (tx, rx) = mpsc::unbounded_channel(); - MessageQueue { tx: tx.into(), rx } -} - -/// Test-only wrapper for MPSC receiver, to test what messages have been queued -pub struct MessageQueue { - tx: MessageSender, - rx: UnboundedReceiver, -} - -impl MessageQueue { - /// Get the message sender - pub fn tx(&self) -> &MessageSender { - &self.tx - } - - pub fn assert_empty(&mut self) { - let message = self.rx.try_recv().ok(); - assert!( - message.is_none(), - "Expected empty queue, but had message {message:?}" - ); - } - - /// Pop the next message off the queue. Panic if the queue is empty - pub fn pop_now(&mut self) -> Message { - self.rx.try_recv().expect("Message queue empty") - } - - /// Pop the next message off the queue, waiting if empty - pub async fn pop_wait(&mut self) -> Message { - self.rx.recv().await.expect("Message queue closed") - } - - /// Clear all messages in the queue - pub fn clear(&mut self) { - while self.rx.try_recv().is_ok() {} - } -} - /// Return a static value when prompted, or no value if none is given #[derive(Debug, Default)] pub struct TestPrompter { @@ -408,7 +158,7 @@ pub(crate) use assert_err; /// values from the pattern using the `=>` syntax. macro_rules! assert_matches { ($expr:expr, $pattern:pat $(,)?) => { - assert_matches!($expr, $pattern => ()); + crate::test_util::assert_matches!($expr, $pattern => ()); }; ($expr:expr, $pattern:pat => $bindings:expr $(,)?) => { match $expr { @@ -422,14 +172,3 @@ macro_rules! assert_matches { }; } pub(crate) use assert_matches; - -/// Assert that the event queue matches the given list of patterns -macro_rules! assert_events { - ($($pattern:pat),* $(,)?) => { - ViewContext::inspect_event_queue(|events| { - assert_matches!(events, &[$($pattern,)*]); - }); - } -} -pub(crate) use assert_events; -use rstest::fixture; diff --git a/src/tui.rs b/src/tui.rs index 2b925161..bfea979a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,8 @@ pub mod context; pub mod input; pub mod message; +#[cfg(test)] +pub mod test_util; mod util; pub mod view; diff --git a/src/tui/context.rs b/src/tui/context.rs index b6984313..6a36fc4e 100644 --- a/src/tui/context.rs +++ b/src/tui/context.rs @@ -32,17 +32,28 @@ pub struct TuiContext { impl TuiContext { /// Initialize global context. Should be called only once, during startup. pub fn init(config: Config) { + INSTANCE + .set(Self::new(config)) + .expect("Global context is already initialized"); + } + + /// Initialize the global context for tests. This will use a default config, + /// and if the context is already initialized, do nothing. + #[cfg(test)] + pub fn init_test() { + INSTANCE.get_or_init(|| Self::new(Config::default())); + } + + fn new(config: Config) -> Self { let styles = Styles::new(&config.theme); let input_engine = InputEngine::new(config.input_bindings.clone()); let http_engine = HttpEngine::new(&config); - INSTANCE - .set(Self { - config, - styles, - input_engine, - http_engine, - }) - .expect("Global context is already initialized"); + Self { + config, + styles, + input_engine, + http_engine, + } } /// Get a reference to the global context diff --git a/src/tui/input.rs b/src/tui/input.rs index 6fc99434..6f90c4f8 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -558,7 +558,10 @@ fn stringify_key_modifier(modifier: KeyModifiers) -> Cow<'static, str> { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::{ + test_util::{assert_err, assert_matches}, + tui::test_util::{harness, TestHarness}, + }; use crossterm::event::{KeyEventState, MediaKeyCode}; use rstest::rstest; use serde_test::{assert_de_tokens, assert_de_tokens_error, Token}; @@ -627,14 +630,14 @@ mod tests { )] #[case::paste(Event::Paste("hello!".into()), None)] fn test_handle_event_queued( - mut messages: MessageQueue, + mut harness: TestHarness, #[case] event: Event, #[case] expected_action: Option, ) { let engine = InputEngine::new(IndexMap::default()); - engine.handle_event(messages.tx(), event.clone()); + engine.handle_event(harness.messages_tx(), event.clone()); let (queued_event, queued_action) = assert_matches!( - messages.pop_now(), + harness.pop_message_now(), Message::Input { event, action } => (event, action), ); assert_eq!(queued_event, event); @@ -651,12 +654,12 @@ mod tests { #[case::mouse_drag(mouse_event(MouseEventKind::Drag(MouseButton::Left)))] #[case::mouse_move(mouse_event(MouseEventKind::Moved))] fn test_handle_event_killed( - mut messages: MessageQueue, + mut harness: TestHarness, #[case] event: Event, ) { let engine = InputEngine::new(IndexMap::default()); - engine.handle_event(messages.tx(), event); - messages.assert_empty(); + engine.handle_event(harness.messages_tx(), event); + harness.assert_messages_empty(); } #[rstest] diff --git a/src/tui/test_util.rs b/src/tui/test_util.rs new file mode 100644 index 00000000..b1522a16 --- /dev/null +++ b/src/tui/test_util.rs @@ -0,0 +1,97 @@ +//! Test utilities specific to the TUI + +use crate::{ + db::CollectionDatabase, + test_util::Factory, + tui::{ + context::TuiContext, + message::{Message, MessageSender}, + view::ViewContext, + }, +}; +use ratatui::{backend::TestBackend, Terminal}; +use rstest::fixture; +use tokio::sync::mpsc::{self, UnboundedReceiver}; + +/// Get a test harness, with a clean terminal etc. See [TestHarness]. +#[fixture] +pub fn harness(terminal_width: u16, terminal_height: u16) -> TestHarness { + TuiContext::init_test(); + let (messages_tx, messages_rx) = mpsc::unbounded_channel(); + let messages_tx: MessageSender = messages_tx.into(); + let database = CollectionDatabase::factory(()); + ViewContext::init(database.clone(), messages_tx.clone()); + let backend = TestBackend::new(terminal_width, terminal_height); + let terminal = Terminal::new(backend).unwrap(); + TestHarness { + database, + messages_tx, + messages_rx, + terminal, + } +} + +/// Terminal width in chars, for injection to [harness] fixture +#[fixture] +fn terminal_width() -> u16 { + 40 +} + +/// Terminal height in chars, for injection to [harness] fixture +#[fixture] +fn terminal_height() -> u16 { + 20 +} + +/// A container for all singleton types needed for tests. Most TUI tests will +/// need one of these. This should be your interface for modifying any global +/// state. +pub struct TestHarness { + // These are public because we don't care about external mutation + pub database: CollectionDatabase, + pub terminal: Terminal, + messages_tx: MessageSender, + messages_rx: UnboundedReceiver, +} + +impl TestHarness { + /// Get the message sender + pub fn messages_tx(&self) -> &MessageSender { + &self.messages_tx + } + + /// Assert the message queue is empty. Requires `&mut self` because it will + /// actually pop a message off the queue + pub fn assert_messages_empty(&mut self) { + let message = self.messages_rx.try_recv().ok(); + assert!( + message.is_none(), + "Expected empty queue, but had message {message:?}" + ); + } + + /// Pop the next message off the queue. Panic if the queue is empty + pub fn pop_message_now(&mut self) -> Message { + self.messages_rx.try_recv().expect("Message queue empty") + } + + /// Pop the next message off the queue, waiting if empty + pub async fn pop_message_wait(&mut self) -> Message { + self.messages_rx.recv().await.expect("Message queue closed") + } + + /// Clear all messages in the queue + pub fn clear_messages(&mut self) { + while self.messages_rx.try_recv().is_ok() {} + } +} + +/// Assert that the event queue matches the given list of patterns +macro_rules! assert_events { + ($($pattern:pat),* $(,)?) => { + ViewContext::inspect_event_queue(|events| { + crate::test_util::assert_matches!(events, &[$($pattern,)*]); + }); + } +} +pub(crate) use assert_events; diff --git a/src/tui/util.rs b/src/tui/util.rs index 4cd3732f..7a6839f2 100644 --- a/src/tui/util.rs +++ b/src/tui/util.rs @@ -168,7 +168,10 @@ async fn confirm(messages_tx: &MessageSender, message: impl ToString) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::{ + test_util::{assert_matches, temp_dir, TempDir}, + tui::test_util::{harness, TestHarness}, + }; use rstest::rstest; use tokio::fs; @@ -179,8 +182,8 @@ mod tests { #[case::old_file_overwrite(true, true)] #[tokio::test] async fn test_save_file( + mut harness: TestHarness, temp_dir: TempDir, - mut messages: MessageQueue, #[case] exists: bool, #[case] overwrite: bool, ) { @@ -191,14 +194,14 @@ mod tests { // This will run in the background and save the file after prompts let handle = tokio::spawn(save_file( - messages.tx().clone(), + harness.messages_tx().clone(), Some("default.txt".into()), b"hello!".to_vec(), )); // First we expect a prompt for the file path let prompt = assert_matches!( - messages.pop_wait().await, + harness.pop_message_wait().await, Message::PromptStart(prompt) => prompt, ); assert_eq!(&prompt.message, "Enter a path for the file"); @@ -210,7 +213,7 @@ mod tests { if exists { // Now we expect a confirmation prompt let confirm = assert_matches!( - messages.pop_wait().await, + harness.pop_message_wait().await, Message::ConfirmStart(confirm) => confirm, ); assert_eq!( diff --git a/src/tui/view.rs b/src/tui/view.rs index 3b1214a5..ed922fd9 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -4,10 +4,13 @@ mod context; mod draw; mod event; mod state; +#[cfg(test)] +pub mod test_util; mod theme; mod util; pub use common::modal::{IntoModal, ModalPriority}; +pub use context::ViewContext; pub use state::RequestState; pub use theme::{Styles, Theme}; pub use util::{Confirm, PreviewPrompter}; @@ -20,7 +23,6 @@ use crate::{ message::{Message, MessageSender}, view::{ component::{Component, Root}, - context::ViewContext, event::{Event, Update}, state::Notification, }, @@ -154,23 +156,22 @@ impl View { mod tests { use super::*; use crate::{ - collection::Collection, test_util::*, tui::context::TuiContext, + collection::Collection, + test_util::Factory, + tui::test_util::{assert_events, harness, TestHarness}, }; - use ratatui::{backend::TestBackend, Terminal}; use rstest::rstest; /// Test view handling and drawing during initial view setup #[rstest] - fn test_initial_draw( - _tui_context: &TuiContext, - mut terminal: Terminal, - database: CollectionDatabase, - messages: MessageQueue, - ) { + fn test_initial_draw(mut harness: TestHarness) { let collection = Collection::factory(()); let collection_file = CollectionFile::testing(collection); - let mut view = - View::new(&collection_file, database, messages.tx().clone()); + let mut view = View::new( + &collection_file, + harness.database.clone(), + harness.messages_tx().clone(), + ); // Initial events assert_events!( @@ -188,7 +189,7 @@ mod tests { ); // Nothing new - view.draw(&mut terminal.get_frame()); + view.draw(&mut harness.terminal.get_frame()); assert_events!( Event::HttpSelectRequest(None), Event::Other(_), diff --git a/src/tui/view/common/template_preview.rs b/src/tui/view/common/template_preview.rs index a9490240..5ca85a93 100644 --- a/src/tui/view/common/template_preview.rs +++ b/src/tui/view/common/template_preview.rs @@ -193,7 +193,8 @@ mod tests { use crate::{ collection::{Collection, Profile}, template::TemplateContext, - test_util::*, + test_util::Factory, + tui::test_util::{harness, TestHarness}, }; use indexmap::indexmap; use rstest::rstest; @@ -210,7 +211,7 @@ mod tests { /// offset indexes work correctly #[rstest] #[tokio::test] - async fn test_template_stitch(tui_context: &TuiContext) { + async fn test_template_stitch(_harness: TestHarness) { // Render a template let template = Template::parse( "intro\n{{user_id}} 💚💙💜 {{unknown}}\noutro\r\nmore outro".into(), @@ -232,7 +233,7 @@ mod tests { ..TemplateContext::factory(()) }; let chunks = template.render_chunks(&context).await; - let styles = &tui_context.styles; + let styles = &TuiContext::get().styles; let text = TextStitcher::stitch_chunks(&template, &chunks); let rendered_style = styles.template_preview.text; diff --git a/src/tui/view/common/text_box.rs b/src/tui/view/common/text_box.rs index 8b08d1df..d77e256b 100644 --- a/src/tui/view/common/text_box.rs +++ b/src/tui/view/common/text_box.rs @@ -350,12 +350,11 @@ impl PersistentContainer for TextBox { #[cfg(test)] mod tests { use super::*; - use crate::{ - db::CollectionDatabase, - test_util::*, - tui::view::{component::Component, context::ViewContext}, + use crate::tui::{ + test_util::{harness, TestHarness}, + view::test_util::TestComponent, }; - use ratatui::{backend::TestBackend, text::Span, Terminal}; + use ratatui::text::Span; use rstest::rstest; use std::{cell::Cell, rc::Rc}; @@ -406,134 +405,98 @@ mod tests { /// Test the basic interaction loop on the text box #[rstest] - fn test_interaction( - _tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, - #[with(10, 1)] mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_interaction(#[with(10, 1)] harness: TestHarness) { let click_count = Counter::default(); let submit_count = Counter::default(); let cancel_count = Counter::default(); - let mut component: Component<_> = TextBox::default() - .with_on_click(click_count.callback()) - .with_on_submit(submit_count.callback()) - .with_on_cancel(cancel_count.callback()) - .into(); - component.draw_term(&mut terminal, ()); + let mut component = TestComponent::new( + harness, + TextBox::default() + .with_on_click(click_count.callback()) + .with_on_submit(submit_count.callback()) + .with_on_cancel(cancel_count.callback()), + (), + ); // Assert initial state/view assert_state(&component.data().state, "", 0); - terminal - .backend() - .assert_buffer_lines([vec![cursor(" "), text(" ")]]); + component.assert_buffer_lines([vec![cursor(" "), text(" ")]]); // Type some text - ViewContext::send_text("hello!"); - component.drain_events(); - component.draw_term(&mut terminal, ()); + component.send_text("hello!").assert_empty(); assert_state(&component.data().state, "hello!", 6); - terminal.backend().assert_buffer_lines([vec![ + component.assert_buffer_lines([vec![ text("hello!"), cursor(" "), text(" "), ]]); // Test callbacks - ViewContext::click(0, 0); - component.drain_events(); + component.click(0, 0).assert_empty(); assert_eq!(click_count, 1); - ViewContext::send_key(KeyCode::Enter); - component.drain_events(); + component.send_key(KeyCode::Enter).assert_empty(); assert_eq!(submit_count, 1); - ViewContext::send_key(KeyCode::Esc); - component.drain_events(); + component.send_key(KeyCode::Esc).assert_empty(); assert_eq!(cancel_count, 1); } /// Test text navigation and deleting. [TextState] has its own tests so /// we're mostly just testing that keys are mapped correctly #[rstest] - fn test_navigation( - _tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, - #[with(10, 1)] mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let mut component: Component<_> = TextBox::default().into(); - component.draw_term(&mut terminal, ()); + fn test_navigation(#[with(10, 1)] harness: TestHarness) { + let mut component = TestComponent::new(harness, TextBox::default(), ()); // Type some text - ViewContext::send_text("hello!"); - component.drain_events(); + component.send_text("hello!").assert_empty(); assert_state(&component.data().state, "hello!", 6); // Move around, delete some text. - ViewContext::send_key(KeyCode::Left); - component.drain_events(); + component.send_key(KeyCode::Left).assert_empty(); assert_state(&component.data().state, "hello!", 5); - ViewContext::send_key(KeyCode::Backspace); - component.drain_events(); + component.send_key(KeyCode::Backspace).assert_empty(); assert_state(&component.data().state, "hell!", 4); - ViewContext::send_key(KeyCode::Delete); - component.drain_events(); + component.send_key(KeyCode::Delete).assert_empty(); assert_state(&component.data().state, "hell", 4); - ViewContext::send_key(KeyCode::Home); - component.drain_events(); + component.send_key(KeyCode::Home).assert_empty(); assert_state(&component.data().state, "hell", 0); - ViewContext::send_key(KeyCode::Right); - component.drain_events(); + component.send_key(KeyCode::Right).assert_empty(); assert_state(&component.data().state, "hell", 1); - ViewContext::send_key(KeyCode::End); - component.drain_events(); + component.send_key(KeyCode::End).assert_empty(); assert_state(&component.data().state, "hell", 4); } #[rstest] - fn test_sensitive( - _tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, - #[with(6, 1)] mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let mut component: Component<_> = - TextBox::default().with_sensitive(true).into(); - component.draw_term(&mut terminal, ()); - ViewContext::send_text("hello"); - component.drain_events(); - component.draw_term(&mut terminal, ()); + fn test_sensitive(#[with(6, 1)] harness: TestHarness) { + let mut component = TestComponent::new( + harness, + TextBox::default().with_sensitive(true), + (), + ); + + component.send_text("hello").assert_empty(); assert_state(&component.data().state, "hello", 5); - terminal - .backend() - .assert_buffer_lines([vec![text("•••••"), cursor(" ")]]); + component.assert_buffer_lines([vec![text("•••••"), cursor(" ")]]); } #[rstest] - fn test_placeholder( - tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, - #[with(6, 1)] mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let component: Component<_> = - TextBox::default().with_placeholder("hello").into(); - component.draw_term(&mut terminal, ()); + fn test_placeholder(#[with(6, 1)] harness: TestHarness) { + let component = TestComponent::new( + harness, + TextBox::default().with_placeholder("hello"), + (), + ); assert_state(&component.data().state, "", 0); - let styles = &tui_context.styles.text_box; - terminal.backend().assert_buffer_lines([vec![ + let styles = &TuiContext::get().styles.text_box; + component.assert_buffer_lines([vec![ cursor("h"), Span::styled("ello", styles.text.patch(styles.placeholder)), text(" "), @@ -541,34 +504,25 @@ mod tests { } #[rstest] - fn test_validator( - tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, - #[with(6, 1)] mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let mut component: Component<_> = TextBox::default() - .with_validator(|text| text.len() <= 2) - .into(); - component.draw_term(&mut terminal, ()); + fn test_validator(#[with(6, 1)] harness: TestHarness) { + let mut component = TestComponent::new( + harness, + TextBox::default().with_validator(|text| text.len() <= 2), + (), + ); // Valid text, everything is normal - ViewContext::send_text("he"); - component.drain_events(); - component.draw_term(&mut terminal, ()); - terminal.backend().assert_buffer_lines([vec![ + component.send_text("he").assert_empty(); + component.assert_buffer_lines([vec![ text("he"), cursor(" "), text(" "), ]]); // Invalid text, styling changes - ViewContext::send_text("llo"); - component.drain_events(); - component.draw_term(&mut terminal, ()); - terminal.backend().assert_buffer_lines([vec![ - Span::styled("hello", tui_context.styles.text_box.invalid), + component.send_text("llo").assert_empty(); + component.assert_buffer_lines([vec![ + Span::styled("hello", TuiContext::get().styles.text_box.invalid), cursor(" "), ]]); } diff --git a/src/tui/view/component/internal.rs b/src/tui/view/component/internal.rs index 3b2f4f65..97154bee 100644 --- a/src/tui/view/component/internal.rs +++ b/src/tui/view/component/internal.rs @@ -69,6 +69,11 @@ impl Component { } } + /// Name of the component type, which is used just for debugging/tracing + pub fn name(&self) -> &'static str { + self.name + } + /// Handle an event for this component *or* its children, starting at the /// lowest descendant. Recursively walk up the tree until a component /// consumes the event. @@ -103,7 +108,7 @@ impl Component { // None of our children handled it, we'll take it ourselves. Event is // already traced in the root span, so don't dupe it. - let span = trace_span!("Component handling", component = self.name); + let span = trace_span!("Component handling", component = self.name()); span.in_scope(|| { let update = self.data_mut().update(event); trace!(?update); @@ -216,51 +221,6 @@ impl Component { } } -/// Test-only helpers -#[cfg(test)] -impl Component { - /// Draw this component onto the terminal. Prefer this over calling - /// [Component::draw] directly, because that won't update Ratatui's internal - /// buffers correctly. The entire frame area will be used to draw the - /// component. - pub fn draw_term( - &self, - terminal: &mut ratatui::Terminal, - props: Props, - ) where - T: Draw, - { - terminal - .draw(|frame| self.draw(frame, props, frame.size(), true)) - .unwrap(); - } - - /// Drain events from the event queue, and handle them one-by-one. We expect - /// each event to be consumed, so panic if it's propagated. - pub fn drain_events(&mut self) - where - T: EventHandler, - { - use crate::tui::view::ViewContext; - - // Safety check, prevent annoying bugs - assert!( - self.is_visible(), - "Component {} is not visible, it can't handle events", - self.name - ); - - while let Some(event) = ViewContext::pop_event() { - match self.update_all(event) { - Update::Consumed => {} - Update::Propagate(event) => { - panic!("Event was not consumed: {event:?}") - } - } - } - } -} - impl From for Component { fn from(inner: T) -> Self { Self::new(inner) @@ -346,14 +306,18 @@ impl Drop for DrawGuard { mod tests { use super::*; use crate::{ - test_util::*, - tui::{input::Action, view::event::Update}, + test_util::assert_matches, + tui::{ + input::Action, + test_util::{harness, TestHarness}, + view::event::Update, + }, }; use crossterm::event::{ KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEventKind, }; - use ratatui::{backend::TestBackend, layout::Layout, Terminal}; + use ratatui::layout::Layout; use rstest::{fixture, rstest}; #[derive(Debug, Default)] @@ -449,11 +413,6 @@ mod tests { } } - #[fixture] - fn component() -> Component { - Component::default() - } - fn keyboard_event() -> Event { Event::Input { event: crossterm::event::Event::Key(KeyEvent { @@ -478,13 +437,21 @@ mod tests { } } + /// Get a testing component. This *doesn't* use `TestComponent` because + /// we want to test logic internal to the component, so we need to do some + /// wonky things unique to these tests that require calling the component + /// methods directly. + #[fixture] + fn component() -> Component { + Component::default() + } + /// Render a simple component tree and test that events are propagated as /// expected, and that state updates as the visible and focused components /// change. #[rstest] fn test_render_component_tree( - _messages: MessageQueue, - mut terminal: Terminal, + mut harness: TestHarness, mut component: Component, ) { // One level of nesting @@ -541,8 +508,9 @@ mod tests { assert_events(&mut component, [0, 0, 0, 0]); // Visible components get events + let mut frame = harness.terminal.get_frame(); component.draw( - &mut terminal.get_frame(), + &mut frame, Props { a: Mode::Focused, b: Mode::Visible, @@ -559,7 +527,7 @@ mod tests { // Switch things up, make sure new state is reflected component.draw( - &mut terminal.get_frame(), + &mut frame, Props { a: Mode::Visible, b: Mode::Hidden, @@ -576,7 +544,7 @@ mod tests { // Hide all children, root should eat everything component.draw( - &mut terminal.get_frame(), + &mut frame, Props { a: Mode::Hidden, b: Mode::Hidden, @@ -593,12 +561,14 @@ mod tests { /// happen in the wild, but it's good to have it be well-defined. #[rstest] fn test_parent_hidden( - mut terminal: Terminal, + mut harness: TestHarness, mut component: Component, ) { - component.data().a.draw_term(&mut terminal, ()); - component.data().b.draw_term(&mut terminal, ()); - component.data().c.draw_term(&mut terminal, ()); + let mut frame = harness.terminal.get_frame(); + let area = frame.size(); + component.data().a.draw(&mut frame, (), area, true); + component.data().b.draw(&mut frame, (), area, true); + component.data().c.draw(&mut frame, (), area, true); // Event should *not* be handled because the parent is hidden assert_matches!( component.update_all(keyboard_event()), @@ -610,24 +580,22 @@ mod tests { /// *not* receive focus-only events. #[rstest] fn test_parent_unfocused( - mut terminal: Terminal, + mut harness: TestHarness, mut component: Component, ) { // We are visible but *not* in focus - terminal - .draw(|frame| { - component.draw( - frame, - Props { - a: Mode::Focused, - b: Mode::Visible, - c: Mode::Visible, - }, - frame.size(), - false, - ) - }) - .unwrap(); + let mut frame = harness.terminal.get_frame(); + let area = frame.size(); + component.draw( + &mut frame, + Props { + a: Mode::Focused, + b: Mode::Visible, + c: Mode::Visible, + }, + area, + false, + ); // Event should *not* be handled because the parent is unfocused assert_matches!( component.update_all(keyboard_event()), diff --git a/src/tui/view/component/primary.rs b/src/tui/view/component/primary.rs index 591ccfb7..27659b5f 100644 --- a/src/tui/view/component/primary.rs +++ b/src/tui/view/component/primary.rs @@ -51,6 +51,7 @@ pub struct PrimaryView { record_pane: Component, } +#[cfg_attr(test, derive(Clone))] pub struct PrimaryViewProps<'a> { pub selected_request: Option<&'a RequestState>, } @@ -384,60 +385,51 @@ impl<'a> Draw> for PrimaryView { mod tests { use super::*; use crate::{ - db::CollectionDatabase, http::RecipeOptions, - test_util::*, + test_util::{assert_matches, Factory}, tui::{ - context::TuiContext, message::{Message, RequestConfig}, + test_util::{harness, TestHarness}, + view::test_util::TestComponent, }, }; - use ratatui::{backend::TestBackend, Terminal}; use rstest::{fixture, rstest}; - /// Create component to be tested. Return the associated message queue too, - /// so it can be tested + /// Create component to be tested #[fixture] fn component( - _tui_context: &TuiContext, - database: CollectionDatabase, - mut messages: MessageQueue, - mut terminal: Terminal, - ) -> (MessageQueue, Component) { - ViewContext::init(database, messages.tx().clone()); + harness: TestHarness, + ) -> TestComponent> { let collection = Collection::factory(()); - let component: Component = - PrimaryView::new(&collection).into(); - - // Draw once to initialize state - component.draw_term( - &mut terminal, + let mut component = TestComponent::new( + harness, + PrimaryView::new(&collection), PrimaryViewProps { selected_request: None, }, ); // Clear template preview messages so we can test what we want - messages.clear(); - (messages, component) + component.harness_mut().clear_messages(); + component } /// Test "Copy URL" action, which is available via the Recipe List or Recipe /// panes #[rstest] - fn test_copy_url(component: (MessageQueue, Component)) { - let (mut messages, mut component) = component; - assert_matches!( - component.update_all(Event::new_other(RecipeMenuAction::CopyUrl)), - Update::Consumed - ); + fn test_copy_url( + mut component: TestComponent>, + ) { + component + .update_draw(Event::new_other(RecipeMenuAction::CopyUrl)) + .assert_empty(); - let message = messages.pop_now(); - let Message::CopyRequestUrl(request_config) = &message else { - panic!("Wrong message: {message:?}") - }; + let request_config = assert_matches!( + component.harness_mut().pop_message_now(), + Message::CopyRequestUrl(request_config) => request_config, + ); assert_eq!( request_config, - &RequestConfig { + RequestConfig { recipe_id: "recipe1".into(), profile_id: Some("profile1".into()), options: RecipeOptions::default() @@ -448,20 +440,20 @@ mod tests { /// Test "Copy Body" action, which is available via the Recipe List or /// Recipe panes #[rstest] - fn test_copy_body(component: (MessageQueue, Component)) { - let (mut messages, mut component) = component; - assert_matches!( - component.update_all(Event::new_other(RecipeMenuAction::CopyBody)), - Update::Consumed - ); + fn test_copy_body( + mut component: TestComponent>, + ) { + component + .update_draw(Event::new_other(RecipeMenuAction::CopyBody)) + .assert_empty(); - let message = messages.pop_now(); - let Message::CopyRequestBody(request_config) = &message else { - panic!("Wrong message: {message:?}") - }; + let request_config = assert_matches!( + component.harness_mut().pop_message_now(), + Message::CopyRequestBody(request_config) => request_config, + ); assert_eq!( request_config, - &RequestConfig { + RequestConfig { recipe_id: "recipe1".into(), profile_id: Some("profile1".into()), options: RecipeOptions::default() @@ -472,20 +464,20 @@ mod tests { /// Test "Copy as cURL" action, which is available via the Recipe List or /// Recipe panes #[rstest] - fn test_copy_as_curl(component: (MessageQueue, Component)) { - let (mut messages, mut component) = component; - assert_matches!( - component.update_all(Event::new_other(RecipeMenuAction::CopyCurl)), - Update::Consumed - ); + fn test_copy_as_curl( + mut component: TestComponent>, + ) { + component + .update_draw(Event::new_other(RecipeMenuAction::CopyCurl)) + .assert_empty(); - let message = messages.pop_now(); - let Message::CopyRequestCurl(request_config) = &message else { - panic!("Wrong message: {message:?}") - }; + let request_config = assert_matches!( + component.harness_mut().pop_message_now(), + Message::CopyRequestCurl(request_config) => request_config, + ); assert_eq!( request_config, - &RequestConfig { + RequestConfig { recipe_id: "recipe1".into(), profile_id: Some("profile1".into()), options: RecipeOptions::default() diff --git a/src/tui/view/component/record_body.rs b/src/tui/view/component/record_body.rs index e48e98d6..05fc2d6d 100644 --- a/src/tui/view/component/record_body.rs +++ b/src/tui/view/component/record_body.rs @@ -46,6 +46,7 @@ pub struct RecordBody { query_text_box: Component>, } +#[derive(Clone)] pub struct RecordBodyProps<'a> { pub body: &'a Body, } @@ -210,11 +211,17 @@ fn init_text_window( mod tests { use super::*; use crate::{ - collection::RecipeId, db::CollectionDatabase, http::Response, - test_util::*, tui::context::TuiContext, + collection::RecipeId, + http::Response, + test_util::{header_map, Factory}, + tui::{ + context::TuiContext, + test_util::{harness, TestHarness}, + view::test_util::TestComponent, + }, }; use crossterm::event::KeyCode; - use ratatui::{backend::TestBackend, text::Span, Terminal}; + use ratatui::text::Span; use reqwest::StatusCode; use rstest::{fixture, rstest}; @@ -239,16 +246,13 @@ mod tests { /// Render an unparsed body with no query box #[rstest] - fn test_unparsed( - _tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, - #[with(30, 2)] mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let component: Component<_> = RecordBody::new(None).into(); + fn test_unparsed(#[with(30, 2)] harness: TestHarness) { let body = Body::new(TEXT.into()); - component.draw_term(&mut terminal, RecordBodyProps { body: &body }); + let component = TestComponent::new( + harness, + RecordBody::new(None), + RecordBodyProps { body: &body }, + ); // Assert state let data = component.data(); @@ -260,7 +264,7 @@ mod tests { assert_eq!(data.query, None); // Assert view - terminal.backend().assert_buffer_lines([ + component.assert_buffer_lines([ vec![gutter("1"), " {\"greeting\":\"hello\"} ".into()], vec![gutter(" "), " ".into()], ]); @@ -269,18 +273,16 @@ mod tests { /// Render a parsed body with query text box #[rstest] fn test_parsed( - tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, + #[with(32, 5)] harness: TestHarness, json_response: Response, - #[with(32, 5)] mut terminal: Terminal, ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let mut component: Component<_> = RecordBody::new(None).into(); - let props = || RecordBodyProps { - body: &json_response.body, - }; - component.draw_term(&mut terminal, props()); + let mut component = TestComponent::new( + harness, + RecordBody::new(None), + RecordBodyProps { + body: &json_response.body, + }, + ); // Assert initial state/view let data = component.data(); @@ -290,8 +292,8 @@ mod tests { data.text().as_deref(), Some("{\n \"greeting\": \"hello\"\n}") ); - let styles = &tui_context.styles.text_box; - terminal.backend().assert_buffer_lines([ + let styles = &TuiContext::get().styles.text_box; + component.assert_buffer_lines([ vec![gutter("1"), " { ".into()], vec![gutter("2"), " \"greeting\": \"hello\"".into()], vec![gutter("3"), " } ".into()], @@ -303,15 +305,9 @@ mod tests { ]); // Type something into the query box - ViewContext::send_key(KeyCode::Char('/')); - component.drain_events(); - // Re-draw to update focus for text box - component.draw_term(&mut terminal, props()); - ViewContext::send_text("$.greeting"); - ViewContext::send_key(KeyCode::Enter); - component.drain_events(); - // Re-draw again to apply query to body - component.draw_term(&mut terminal, props()); + component.send_key(KeyCode::Char('/')).assert_empty(); + component.send_text("$.greeting").assert_empty(); + component.send_key(KeyCode::Enter).assert_empty(); // Make sure state updated correctly let data = component.data(); @@ -319,7 +315,7 @@ mod tests { assert_eq!(data.text().as_deref(), Some("[\n \"hello\"\n]")); // Check the view again too - terminal.backend().assert_buffer_lines([ + component.assert_buffer_lines([ vec![gutter("1"), " [ ".into()], vec![gutter("2"), " \"hello\" ".into()], vec![gutter("3"), " ] ".into()], @@ -331,12 +327,9 @@ mod tests { ]); // Cancelling out of the text box should reset the query value - ViewContext::send_key(KeyCode::Char('/')); - component.drain_events(); - component.draw_term(&mut terminal, props()); - ViewContext::send_text("more text"); - ViewContext::send_key(KeyCode::Esc); - component.drain_events(); + component.send_key(KeyCode::Char('/')).assert_empty(); + component.send_text("more text").assert_empty(); + component.send_key(KeyCode::Esc).assert_empty(); let data = component.data(); assert_eq!(data.query, Some("$.greeting".parse().unwrap())); assert_eq!(data.query_text_box.data().text(), "$.greeting"); @@ -345,32 +338,29 @@ mod tests { /// Render a parsed body with query text box, and initial query from the DB #[rstest] fn test_initial_query( - _tui_context: &TuiContext, - database: CollectionDatabase, - messages: MessageQueue, + #[with(30, 4)] harness: TestHarness, json_response: Response, - #[with(30, 4)] mut terminal: Terminal, ) { - ViewContext::init(database.clone(), messages.tx().clone()); let recipe_id = RecipeId::factory(()); // Add initial query to the DB let persistent_key = PersistentKey::ResponseBodyQuery(recipe_id.clone()); - database.set_ui(&persistent_key, "$.greeting").unwrap(); + harness + .database + .set_ui(&persistent_key, "$.greeting") + .unwrap(); // We already have another test to check that querying works via typing // in the box, so we just need to make sure state is initialized // correctly here - let mut component: Component<_> = - RecordBody::new(Some(persistent_key)).into(); - component.draw_term( - &mut terminal, + let component = TestComponent::new( + harness, + RecordBody::new(Some(persistent_key)), RecordBodyProps { body: &json_response.body, }, ); - component.drain_events(); // Events are triggered during init assert_eq!(component.data().query, Some("$.greeting".parse().unwrap())); } } diff --git a/src/tui/view/component/response_view.rs b/src/tui/view/component/response_view.rs index a7234cf7..3250b7d7 100644 --- a/src/tui/view/component/response_view.rs +++ b/src/tui/view/component/response_view.rs @@ -29,6 +29,7 @@ pub struct ResponseBodyView { state: StateCell, } +#[derive(Clone)] pub struct ResponseBodyViewProps<'a> { pub request_id: RequestId, pub recipe_id: &'a RecipeId, @@ -172,11 +173,14 @@ impl<'a> Draw> for ResponseHeadersView { mod tests { use super::*; use crate::{ - db::CollectionDatabase, http::RequestRecord, test_util::*, - tui::context::TuiContext, + http::RequestRecord, + test_util::{assert_matches, header_map, Factory}, + tui::{ + test_util::{harness, TestHarness}, + view::test_util::TestComponent, + }, }; use indexmap::indexmap; - use ratatui::{backend::TestBackend, Terminal}; use rstest::rstest; /// Test "Copy Body" menu action @@ -200,23 +204,18 @@ mod tests { )] #[tokio::test] async fn test_copy_body( - _tui_context: &TuiContext, - database: CollectionDatabase, - mut messages: MessageQueue, - mut terminal: Terminal, + harness: TestHarness, #[case] response: Response, #[case] expected_body: &str, ) { - ViewContext::init(database.clone(), messages.tx().clone()); - // Draw once to initialize state - let mut component: Component = Component::default(); response.parse_body(); // Normally the view does this let record = RequestRecord { response: response.into(), ..RequestRecord::factory(()) }; - component.draw_term( - &mut terminal, + let mut component = TestComponent::new( + harness, + ResponseBodyView::default(), ResponseBodyViewProps { request_id: record.id, recipe_id: &record.request.recipe_id, @@ -224,15 +223,14 @@ mod tests { }, ); - assert_matches!( - component.update_all(Event::new_other(BodyMenuAction::CopyBody)), - Update::Consumed - ); + component + .update_draw(Event::new_other(BodyMenuAction::CopyBody)) + .assert_empty(); - let message = messages.pop_now(); - let Message::CopyText(body) = &message else { - panic!("Wrong message: {message:?}") - }; + let body = assert_matches!( + component.harness_mut().pop_message_now(), + Message::CopyText(body) => body, + ); assert_eq!(body, expected_body); } @@ -271,25 +269,19 @@ mod tests { )] #[tokio::test] async fn test_save_file( - _tui_context: &TuiContext, - database: CollectionDatabase, - mut messages: MessageQueue, - mut terminal: Terminal, + harness: TestHarness, #[case] response: Response, #[case] expected_body: &[u8], #[case] expected_path: &str, ) { - ViewContext::init(database.clone(), messages.tx().clone()); - let mut component: Component = Component::default(); response.parse_body(); // Normally the view does this let record = RequestRecord { response: response.into(), ..RequestRecord::factory(()) }; - - // Draw once to initialize state - component.draw_term( - &mut terminal, + let mut component = TestComponent::new( + harness, + ResponseBodyView::default(), ResponseBodyViewProps { request_id: record.id, recipe_id: &record.request.recipe_id, @@ -297,15 +289,14 @@ mod tests { }, ); - assert_matches!( - component.update_all(Event::new_other(BodyMenuAction::SaveBody)), - Update::Consumed - ); + component + .update_draw(Event::new_other(BodyMenuAction::SaveBody)) + .assert_empty(); - let message = messages.pop_now(); - let Message::SaveFile { data, default_path } = &message else { - panic!("Wrong message: {message:?}") - }; + let (data, default_path) = assert_matches!( + component.harness_mut().pop_message_now(), + Message::SaveFile { data, default_path } => (data, default_path), + ); assert_eq!(data, expected_body); assert_eq!(default_path.as_deref(), Some(expected_path)); } diff --git a/src/tui/view/component/root.rs b/src/tui/view/component/root.rs index 6a1e6387..2a794679 100644 --- a/src/tui/view/component/root.rs +++ b/src/tui/view/component/root.rs @@ -97,7 +97,7 @@ impl Root { } /// Open the history modal for current recipe+profile. Return an error if - /// the database load failed. + /// the harness.database load failed. fn open_history(&mut self) -> anyhow::Result<()> { let primary_view = self.primary_view.data(); if let Some(recipe) = primary_view.selected_recipe() { @@ -235,20 +235,21 @@ impl PersistentContainer for SelectedRequestId { #[cfg(test)] mod tests { use super::*; - use crate::{db::CollectionDatabase, http::RequestRecord, test_util::*}; + use crate::{ + http::RequestRecord, + test_util::{assert_matches, Factory}, + tui::{ + test_util::{harness, TestHarness}, + view::test_util::TestComponent, + }, + }; use crossterm::event::KeyCode; - use ratatui::{backend::TestBackend, Terminal}; use rstest::rstest; /// Test that, on first render, the view loads the most recent historical /// request for the first recipe+profile #[rstest] - fn test_preload_request( - database: CollectionDatabase, - messages: MessageQueue, - mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_preload_request(harness: TestHarness) { // Add a request into the DB that we expect to preload let collection = Collection::factory(()); let profile_id = collection.first_profile_id(); @@ -257,27 +258,14 @@ mod tests { Some(profile_id.clone()), recipe_id.clone(), )); - database.insert_request(&record).unwrap(); + harness.database.insert_request(&record).unwrap(); - let mut component: Component = Root::new(&collection).into(); + let component = TestComponent::new(harness, Root::new(&collection), ()); // Make sure profile+recipe were preselected correctly let primary_view = component.data().primary_view.data(); assert_eq!(primary_view.selected_profile_id(), Some(profile_id)); assert_eq!(primary_view.selected_recipe_id(), Some(recipe_id)); - - // Initial draw - component.draw_term(&mut terminal, ()); - - assert_events!( - Event::HttpSelectRequest(None), // From recipe list - Event::Other(_), // Fullscreen exit event - ); - component.drain_events(); - - let primary_view = component.data().primary_view.data(); - assert_eq!(primary_view.selected_recipe_id(), Some(recipe_id)); - assert_eq!(primary_view.selected_profile_id(), Some(profile_id)); assert_eq!( component.data().selected_request(), Some(&RequestState::Response { record }) @@ -290,12 +278,7 @@ mod tests { /// Test that, on first render, if there's a persisted request ID, we load /// up to that instead of selecting the first in the list #[rstest] - fn test_load_persistent_request( - database: CollectionDatabase, - messages: MessageQueue, - mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_load_persistent_request(harness: TestHarness) { let collection = Collection::factory(()); let recipe_id = collection.first_recipe_id(); let profile_id = collection.first_profile_id(); @@ -308,15 +291,16 @@ mod tests { Some(profile_id.clone()), recipe_id.clone(), )); - database.insert_request(&old_record).unwrap(); - database.insert_request(&new_record).unwrap(); - database + harness.database.insert_request(&old_record).unwrap(); + harness.database.insert_request(&new_record).unwrap(); + harness + .database .set_ui(PersistentKey::RequestId, old_record.id) .unwrap(); - let mut component: Component = Root::new(&collection).into(); + let component = TestComponent::new(harness, Root::new(&collection), ()); - // Make sure profile+recipe were preselected correctly + // Make sure everything was preselected correctly assert_eq!( component.data().primary_view.data().selected_profile_id(), Some(profile_id) @@ -325,18 +309,6 @@ mod tests { component.data().primary_view.data().selected_recipe_id(), Some(recipe_id) ); - - // Initial draw - component.draw_term(&mut terminal, ()); - - assert_events!( - Event::HttpSelectRequest(None), // From recipe list - Event::Other(_), - // From persisted value - this comes later so it overrides - Event::HttpSelectRequest(Some(_)), - ); - component.drain_events(); - assert_eq!( component.data().selected_request(), Some(&RequestState::Response { record: old_record }) @@ -344,26 +316,21 @@ mod tests { } #[rstest] - fn test_edit_collection( - database: CollectionDatabase, - mut messages: MessageQueue, - mut terminal: Terminal, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_edit_collection(harness: TestHarness) { let collection = Collection::factory(()); - let mut component: Component = Root::new(&collection).into(); - component.draw_term(&mut terminal, ()); - component.drain_events(); - messages.clear(); // Clear init junk + let mut component = + TestComponent::new(harness, Root::new(&collection), ()); + + component.harness_mut().clear_messages(); // Clear init junk // Event should be converted into a message appropriately // Open action menu - ViewContext::send_key(KeyCode::Char('x')); - component.drain_events(); - component.draw_term(&mut terminal, ()); + component.send_key(KeyCode::Char('x')).assert_empty(); // Select first action - Edit Collection - ViewContext::send_key(KeyCode::Enter); - component.drain_events(); - assert_matches!(messages.pop_now(), Message::CollectionEdit); + component.send_key(KeyCode::Enter).assert_empty(); + assert_matches!( + component.harness_mut().pop_message_now(), + Message::CollectionEdit + ); } } diff --git a/src/tui/view/context.rs b/src/tui/view/context.rs index 18ef59e1..04c17803 100644 --- a/src/tui/view/context.rs +++ b/src/tui/view/context.rs @@ -129,66 +129,19 @@ impl ViewContext { f(refs.as_slice()); }) } - - /// Push a terminal input event onto the event queue. This will include the - /// bound action for the event, based on the key code or mouse button. - pub fn send_input(crossterm_event: crossterm::event::Event) { - use crate::tui::context::TuiContext; - let action = TuiContext::get().input_engine.action(&crossterm_event); - let event = Event::Input { - event: crossterm_event, - action, - }; - ViewContext::push_event(event); - } - - /// Push a left click at the given location onto the event queue - pub fn click(x: u16, y: u16) { - use crossterm::event::{ - KeyModifiers, MouseButton, MouseEvent, MouseEventKind, - }; - let crossterm_event = crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::Up(MouseButton::Left), - column: x, - row: y, - modifiers: KeyModifiers::NONE, - }); - Self::send_input(crossterm_event); - } - - /// Generate an event for a keypress, and push it onto the event queue. - /// This will include the bound action for the key press. - pub fn send_key(code: crossterm::event::KeyCode) { - use crossterm::event::{ - KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, - }; - let crossterm_event = crossterm::event::Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::empty(), - }); - Self::send_input(crossterm_event); - } - - /// Send some text as a series of key events - pub fn send_text(text: &str) { - for c in text.chars() { - Self::send_key(crossterm::event::KeyCode::Char(c)); - } - } } #[cfg(test)] mod tests { use super::*; - use crate::test_util::*; + use crate::{ + test_util::assert_matches, + tui::test_util::{assert_events, harness, TestHarness}, + }; use rstest::rstest; #[rstest] - fn test_event_queue(database: CollectionDatabase, messages: MessageQueue) { - ViewContext::init(database, messages.tx().clone()); - + fn test_event_queue(_harness: TestHarness) { assert_events!(); // Start empty ViewContext::push_event(Event::new_other(3)); @@ -201,14 +154,13 @@ mod tests { } #[rstest] - fn test_send_message( - database: CollectionDatabase, - mut messages: MessageQueue, - ) { - ViewContext::init(database, messages.tx().clone()); + fn test_send_message(mut harness: TestHarness) { ViewContext::send_message(Message::CollectionStartReload); ViewContext::send_message(Message::CollectionEdit); - assert_matches!(messages.pop_now(), Message::CollectionStartReload); - assert_matches!(messages.pop_now(), Message::CollectionEdit); + assert_matches!( + harness.pop_message_now(), + Message::CollectionStartReload + ); + assert_matches!(harness.pop_message_now(), Message::CollectionEdit); } } diff --git a/src/tui/view/draw.rs b/src/tui/view/draw.rs index 00102d5d..f099823a 100644 --- a/src/tui/view/draw.rs +++ b/src/tui/view/draw.rs @@ -12,11 +12,11 @@ use std::{fmt::Display, ops::Deref}; /// /// Props are additional temporary values that a struct may need in order /// to render. Useful for passing down state values that are managed by -/// the parent, to avoid duplicating that state in the child. `Props` probably -/// would make more sense as an associated type, because you generally wouldn't -/// implement `Draw` for a single type with more than one value of `Props`. But -/// attaching a lifetime to the associated type makes using this in a trait -/// object very difficult (maybe impossible?). This is an easy shortcut. +/// the parent, to avoid duplicating that state in the child. In most +/// cases, `Props` would make more sense as an associated type, but there are +/// some component types (e.g. `SelectState`) that have multiple `Draw` impls. +/// Using an associated type also makes prop types with lifetimes much less +/// ergonomic. pub trait Draw { /// Draw the component into the frame. This generally should not be called /// directly. Instead, use diff --git a/src/tui/view/state/persistence.rs b/src/tui/view/state/persistence.rs index 51f46b6d..5fae3fbe 100644 --- a/src/tui/view/state/persistence.rs +++ b/src/tui/view/state/persistence.rs @@ -182,12 +182,11 @@ pub(crate) use impl_persistable; #[cfg(test)] mod tests { use super::*; - use crate::{db::CollectionDatabase, test_util::*}; + use crate::tui::test_util::{harness, TestHarness}; use rstest::rstest; #[rstest] - fn test_persistent(database: CollectionDatabase, messages: MessageQueue) { - ViewContext::init(database, messages.tx().clone()); + fn test_persistent(_harness: TestHarness) { let mut persistent = Persistent::new(PersistentKey::RecipeId, "".to_owned()); *persistent = "hello!".to_owned(); diff --git a/src/tui/view/state/request_store.rs b/src/tui/view/state/request_store.rs index 47926a0a..5fea9b2b 100644 --- a/src/tui/view/state/request_store.rs +++ b/src/tui/view/state/request_store.rs @@ -116,9 +116,9 @@ impl RequestStore { mod tests { use super::*; use crate::{ - db::CollectionDatabase, http::{Request, RequestBuildError, RequestError, RequestRecord}, - test_util::*, + test_util::{assert_err, assert_matches, Factory}, + tui::test_util::{harness, TestHarness}, }; use chrono::Utc; use rstest::rstest; @@ -177,8 +177,7 @@ mod tests { } #[rstest] - fn test_load(database: CollectionDatabase, messages: MessageQueue) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_load(harness: TestHarness) { let mut store = RequestStore::default(); // Generally we would expect this to be in the DB, but in this case omit @@ -188,7 +187,7 @@ mod tests { let missing_record = RequestRecord::factory(()); let missing_id = missing_record.id; - database.insert_request(&missing_record).unwrap(); + harness.database.insert_request(&missing_record).unwrap(); // Already in store, don't fetch store @@ -217,17 +216,16 @@ mod tests { } #[rstest] - fn test_load_latest(database: CollectionDatabase, messages: MessageQueue) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_load_latest(harness: TestHarness) { let profile_id = ProfileId::factory(()); let recipe_id = RecipeId::factory(()); // Create some confounding records, that we don't expected to load - create_record(&database, Some(&profile_id), Some(&recipe_id)); - create_record(&database, Some(&profile_id), None); - create_record(&database, None, Some(&recipe_id)); + create_record(&harness, Some(&profile_id), Some(&recipe_id)); + create_record(&harness, Some(&profile_id), None); + create_record(&harness, None, Some(&recipe_id)); let expected_record = - create_record(&database, Some(&profile_id), Some(&recipe_id)); + create_record(&harness, Some(&profile_id), Some(&recipe_id)); let mut store = RequestStore::default(); assert_eq!( @@ -243,22 +241,18 @@ mod tests { } #[rstest] - fn test_load_summaries( - database: CollectionDatabase, - messages: MessageQueue, - ) { - ViewContext::init(database.clone(), messages.tx().clone()); + fn test_load_summaries(harness: TestHarness) { let profile_id = ProfileId::factory(()); let recipe_id = RecipeId::factory(()); let mut records = (0..5) .map(|_| { - create_record(&database, Some(&profile_id), Some(&recipe_id)) + create_record(&harness, Some(&profile_id), Some(&recipe_id)) }) .collect_vec(); // Create some confounders - create_record(&database, None, Some(&recipe_id)); - create_record(&database, Some(&profile_id), None); + create_record(&harness, None, Some(&recipe_id)); + create_record(&harness, Some(&profile_id), None); // Add one request of each possible state. We expect to get em all back let mut store = RequestStore::default(); @@ -364,7 +358,7 @@ mod tests { /// Create a record with the given profile+recipe ID (or random if /// None), and insert it into the DB fn create_record( - database: &CollectionDatabase, + harness: &TestHarness, profile_id: Option<&ProfileId>, recipe_id: Option<&RecipeId>, ) -> RequestRecord { @@ -376,7 +370,7 @@ mod tests { ), recipe_id.cloned().unwrap_or_else(|| RecipeId::factory(())), )); - database.insert_request(&record).unwrap(); + harness.database.insert_request(&record).unwrap(); record } } diff --git a/src/tui/view/test_util.rs b/src/tui/view/test_util.rs new file mode 100644 index 00000000..c0a00e97 --- /dev/null +++ b/src/tui/view/test_util.rs @@ -0,0 +1,228 @@ +//! Test utilities specific to the TUI *view* + +use crate::tui::{ + context::TuiContext, + test_util::TestHarness, + view::{ + component::Component, + context::ViewContext, + draw::Draw, + event::{Event, EventHandler, Update}, + }, +}; +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, + MouseEvent, MouseEventKind, +}; +use ratatui::text::Line; + +/// A wrapper around a component that makes it easy to test. This provides lots +/// of methods for sending events to the component. The goal is to make +/// realistic testing the easiest option, so component tests aren't contrived or +/// verbose. +pub struct TestComponent { + harness: TestHarness, + component: Component, + /// Whatever props were used for the most recent draw. We store these for + /// convenience, because in most test cases we use the same props over and + /// over, and just care about changes in response to events. This requires + /// that `Props` implements `Clone`, but that's not a problem for most + /// components since props typically just contain identifiers, references, + /// and primitives. + last_props: Props, +} + +impl TestComponent +where + Props: Clone, + T: Draw + EventHandler, +{ + /// Create a new component, then draw it to the screen and drain the event + /// queue. Components aren't useful until they've been drawn once, because + /// they won't receive events until they're marked as visible. For this + /// reason, this constructor takes care of all the things you would + /// immediately have to do anyway. + /// + /// This takes a test harness so it can access the terminal. Most tests only + /// need to interact with a single component, so it's fine to pass ownership + /// of the harness. + pub fn new(harness: TestHarness, data: T, initial_props: Props) -> Self { + let component: Component = data.into(); + let mut slf = Self { + harness, + component, + last_props: initial_props, + }; + // Do an initial draw to set up state, then handle any triggered events + slf.draw(None); + // Ignore any propagated events from initialization. Maybe we *should* + // be checking these, but the mechanics of that aren't smooth. Punting + // for now + let _ = slf.drain_events(); + slf + } + + /// Get a mutable reference to the test harness + pub fn harness_mut(&mut self) -> &mut TestHarness { + &mut self.harness + } + + /// Get a reference to the wrapped component's inner data + pub fn data(&self) -> &T { + self.component.data() + } + + /// Alias for + /// [TestBackend::assert_buffer_lines](ratatui::backend::TestBackend::assert_buffer_lines) + pub fn assert_buffer_lines<'a>( + &self, + expected: impl IntoIterator>>, + ) { + self.harness + .terminal + .backend() + .assert_buffer_lines(expected) + } + + /// Draw this component onto the terminal, using the entire terminal frame + /// as the draw area. If props are given, use them for the draw. If not, + /// use the same props from the last draw. + fn draw(&mut self, props: Option) { + if let Some(props) = props { + self.last_props = props; + } + self.harness + .terminal + .draw(|frame| { + self.component.draw( + frame, + self.last_props.clone(), + frame.size(), + true, + ) + }) + .unwrap(); + } + + /// Drain events from the event queue, and handle them one-by-one. Return + /// the events that were propagated (i.e. not consumed by the component or + /// its children), in the order they were queued/handled. + fn drain_events(&mut self) -> PropagatedEvents { + // Safety check, prevent annoying bugs + assert!( + self.component.is_visible(), + "Component {} is not visible, it can't handle events", + self.component.name() + ); + + let mut propagated = Vec::new(); + while let Some(event) = ViewContext::pop_event() { + if let Update::Propagate(event) = self.component.update_all(event) { + propagated.push(event); + } + } + PropagatedEvents(propagated) + } + + /// Put an event on the event queue, handle **all** events in the queue, + /// then redraw to the screen (using whatever props were used for the last + /// draw). This is the generic "do something in a test" method. Generally + /// any user interaction that you want to simulate in a test should use this + /// method (or one of its callers, like [Self::send_key]). This most closely + /// simulates behavior in the wild, because the TUI will typically re-draw + /// after every user input (unless the user hits two keys *really* quickly). + /// + /// Return whatever events were propagated, so you can test for events that + /// you expect to be generated, but consumed by a parent component that + /// doesn't exist in the test case. This return value should be used, even + /// if you're just checking that it's empty. This is important because + /// propagated events *may* be intentional, but could also indicate a bug + /// where you component isn't handling events it should (or vice versa). + pub fn update_draw(&mut self, event: Event) -> PropagatedEvents { + // This is a safety check, so we don't end up handling events we didn't + // expect to + ViewContext::inspect_event_queue(|queue| { + assert!( + queue.is_empty(), + "Event queue is not empty. To prevent unintended side-effects, \ + the queue must be empty before an update." + ) + }); + ViewContext::push_event(event); + let propagated = self.drain_events(); + self.draw(None); + propagated + } + + /// Push a terminal input event onto the event queue, then drain events and + /// draw. This will include the bound action for the event, based on the key + /// code or mouse button. See [Self::update_draw] about return value. + pub fn send_input( + &mut self, + crossterm_event: crossterm::event::Event, + ) -> PropagatedEvents { + let action = TuiContext::get().input_engine.action(&crossterm_event); + let event = Event::Input { + event: crossterm_event, + action, + }; + self.update_draw(event) + } + + /// Simulate a left click at the given location, then drain events and draw. + /// See [Self::update_draw] about return value. + pub fn click(&mut self, x: u16, y: u16) -> PropagatedEvents { + let crossterm_event = crossterm::event::Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: x, + row: y, + modifiers: KeyModifiers::NONE, + }); + self.send_input(crossterm_event) + } + + /// Simulate a key press on this component. This will generate the + /// corresponding event (including bound action, if any), send it to the + /// component, then drain events and draw. See + /// [Self::update_draw] about return value. + pub fn send_key(&mut self, code: KeyCode) -> PropagatedEvents { + let crossterm_event = crossterm::event::Event::Key(KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }); + self.send_input(crossterm_event) + } + + /// Send some text as a series of key events, handling each event and + /// re-drawing after each character. This may seem wasteful, but it most + /// closely simulates what happens in the real world. Return propagated + /// events from *all* updates, e.g. the concatenation of propagated events + /// from each individual call to [Self::update_draw]. + pub fn send_text(&mut self, text: &str) -> PropagatedEvents { + PropagatedEvents( + text.chars() + .flat_map(|c| self.send_key(KeyCode::Char(c)).0) + .collect(), + ) + } +} + +/// A collection of events that were propagated out from a particular +/// [TestComponent::update_draw] call. This wrapper makes it easy to check +/// which, if any, events were propagated. +#[must_use = "Propagated events must be checked"] +pub struct PropagatedEvents(Vec); + +impl PropagatedEvents { + /// Assert that no events were propagated, i.e. the component handled all + /// given and generated events. + pub fn assert_empty(self) { + assert!( + self.0.is_empty(), + "Expected no propagated events, but got {:?}", + self.0 + ) + } +}