diff --git a/Cargo.lock b/Cargo.lock index 919a47002997..b039db583e95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8883,7 +8883,9 @@ dependencies = [ "re_sdk", "re_server", "re_uri", + "re_view_text_document", "re_viewer", + "re_viewport_blueprint", "tempfile", "tokio", ] diff --git a/crates/viewer/re_viewer/src/app_state.rs b/crates/viewer/re_viewer/src/app_state.rs index adebfc1491e0..56b5444dee3d 100644 --- a/crates/viewer/re_viewer/src/app_state.rs +++ b/crates/viewer/re_viewer/src/app_state.rs @@ -31,6 +31,9 @@ use crate::{ const WATERMARK: bool = false; // Nice for recording media material +#[cfg(feature = "testing")] +pub type TestHookFn = Box)>; + #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] pub struct AppState { @@ -65,6 +68,12 @@ pub struct AppState { #[serde(skip)] pub(crate) share_modal: crate::ui::ShareModal, + /// Test-only: single-shot callback to run at the end of the frame. Used in integration tests + /// to interact with the `ViewerContext`. + #[cfg(feature = "testing")] + #[serde(skip)] + pub(crate) test_hook: Option, + /// A stack of display modes that represents tab-like navigation of the user. #[serde(skip)] pub(crate) navigation: Navigation, @@ -114,6 +123,9 @@ impl Default for AppState { view_states: Default::default(), selection_state: Default::default(), focused_item: Default::default(), + + #[cfg(feature = "testing")] + test_hook: None, } } } @@ -678,6 +690,12 @@ impl AppState { self.open_url_modal.ui(ui); self.share_modal .ui(&ctx, ui, startup_options.web_viewer_base_url().as_ref()); + + // Only in integration tests: call the test hook if any. + #[cfg(feature = "testing")] + if let Some(test_hook) = self.test_hook.take() { + test_hook(&ctx); + } } } diff --git a/crates/viewer/re_viewer/src/viewer_test_utils/app_testing_ext.rs b/crates/viewer/re_viewer/src/viewer_test_utils/app_testing_ext.rs new file mode 100644 index 000000000000..34fcb1e111aa --- /dev/null +++ b/crates/viewer/re_viewer/src/viewer_test_utils/app_testing_ext.rs @@ -0,0 +1,22 @@ +use re_viewer_context::StoreHub; + +use crate::App; + +#[cfg(feature = "testing")] +pub trait AppTestingExt { + fn testonly_get_store_hub(&mut self) -> &mut StoreHub; + fn testonly_set_test_hook(&mut self, func: crate::app_state::TestHookFn); +} + +#[cfg(feature = "testing")] +impl AppTestingExt for App { + fn testonly_get_store_hub(&mut self) -> &mut StoreHub { + self.store_hub + .as_mut() + .expect("store_hub should be initialized") + } + + fn testonly_set_test_hook(&mut self, func: crate::app_state::TestHookFn) { + self.state.test_hook = Some(func); + } +} diff --git a/crates/viewer/re_viewer/src/viewer_test_utils/mod.rs b/crates/viewer/re_viewer/src/viewer_test_utils/mod.rs index 583af7a55f5f..bf908f3cecea 100644 --- a/crates/viewer/re_viewer/src/viewer_test_utils/mod.rs +++ b/crates/viewer/re_viewer/src/viewer_test_utils/mod.rs @@ -1,3 +1,7 @@ +mod app_testing_ext; + +#[cfg(feature = "testing")] +pub use app_testing_ext::AppTestingExt; use egui_kittest::Harness; use re_build_info::build_info; diff --git a/tests/rust/re_integration_test/Cargo.toml b/tests/rust/re_integration_test/Cargo.toml index 52e04191b0ab..82b39a7ee93f 100644 --- a/tests/rust/re_integration_test/Cargo.toml +++ b/tests/rust/re_integration_test/Cargo.toml @@ -11,18 +11,20 @@ version.workspace = true publish = false [dependencies] +egui_kittest.workspace = true +egui.workspace = true re_redap_client.workspace = true re_protos.workspace = true re_sdk.workspace = true re_server.workspace = true re_uri.workspace = true +re_viewer = { workspace = true, features = ["testing"] } +re_viewport_blueprint.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } [dev-dependencies] -re_viewer = { workspace = true, features = ["testing"] } -egui_kittest.workspace = true -egui.workspace = true +re_view_text_document.workspace = true [lints] workspace = true diff --git a/tests/rust/re_integration_test/src/kittest_harness_ext.rs b/tests/rust/re_integration_test/src/kittest_harness_ext.rs new file mode 100644 index 000000000000..51499a0fc5e8 --- /dev/null +++ b/tests/rust/re_integration_test/src/kittest_harness_ext.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; + +use egui_kittest::{SnapshotOptions, kittest::Queryable as _}; +use re_sdk::{ + Component as _, ComponentDescriptor, EntityPath, EntityPathPart, RecordingInfo, StoreId, + StoreKind, + external::{ + re_log_types::{SetStoreInfo, StoreInfo}, + re_tuid::Tuid, + }, + log::Chunk, +}; +use re_viewer::{ + SystemCommand, SystemCommandSender as _, + external::{ + re_chunk::{ChunkBuilder, LatestAtQuery}, + re_entity_db::EntityDb, + re_types, + re_viewer_context::{self, ViewerContext, blueprint_timeline}, + }, + viewer_test_utils::AppTestingExt as _, +}; +use re_viewport_blueprint::ViewportBlueprint; + +// Kittest harness utilities specific to the Rerun app. +pub trait HarnessExt { + // Initializes the chuck store with a new, empty recording and blueprint. + fn init_recording(&mut self); + + // Runs a function with the `ViewerContext` generated by the actual Rerun application. + fn run_with_viewer_context(&mut self, func: impl FnOnce(&ViewerContext<'_>) + 'static); + + // Removes all views and containers from the current blueprint. + fn clear_current_blueprint(&mut self); + + // Sets up a new viewport blueprint and saves the new one in the chunk store. + fn setup_viewport_blueprint( + &mut self, + setup_blueprint: impl FnOnce(&ViewerContext<'_>, &mut ViewportBlueprint) + 'static, + ); + + // Logs an entity to the active recording. + fn log_entity( + &mut self, + entity_path: impl Into, + build_chunk: impl FnOnce(ChunkBuilder) -> ChunkBuilder, + ); + + // Clicks a node in the UI by its label. + fn click_label(&mut self, label: &str); + fn right_click_label(&mut self, label: &str); + + // Takes a snapshot of the current app state with good-enough snapshot options. + fn snapshot_app(&mut self, snapshot_name: &str); + + // Prints the current viewer state. Don't merge code that calls this. + #[allow(unused)] + fn debug_viewer_state(&mut self); + + fn toggle_blueprint_panel(&mut self) { + self.click_label("Blueprint panel toggle"); + } + + fn toggle_time_panel(&mut self) { + self.click_label("Time panel toggle"); + } + + fn toggle_selection_panel(&mut self) { + self.click_label("Selection panel toggle"); + } +} + +impl HarnessExt for egui_kittest::Harness<'_, re_viewer::App> { + fn clear_current_blueprint(&mut self) { + self.setup_viewport_blueprint(|_viewer_context, blueprint| { + for item in blueprint.contents_iter() { + blueprint.remove_contents(item); + } + }); + self.run_ok(); + } + + fn setup_viewport_blueprint( + &mut self, + setup_blueprint: impl FnOnce(&ViewerContext<'_>, &mut ViewportBlueprint) + 'static, + ) { + self.run_with_viewer_context(|viewer_context| { + let blueprint_query = LatestAtQuery::latest(blueprint_timeline()); + let mut viewport_blueprint = + ViewportBlueprint::from_db(viewer_context.blueprint_db(), &blueprint_query); + setup_blueprint(viewer_context, &mut viewport_blueprint); + viewport_blueprint.save_to_blueprint_store(viewer_context); + }); + self.run_ok(); + } + + fn run_with_viewer_context(&mut self, func: impl FnOnce(&ViewerContext<'_>) + 'static) { + self.state_mut().testonly_set_test_hook(Box::new(func)); + self.run_ok(); + } + + fn log_entity( + &mut self, + entity_path: impl Into, + build_chunk: impl FnOnce(ChunkBuilder) -> ChunkBuilder, + ) { + let app = self.state_mut(); + let builder = build_chunk(Chunk::builder(entity_path)); + let store_hub = app.testonly_get_store_hub(); + let active_recording = store_hub + .active_recording_mut() + .expect("active_recording should be initialized"); + active_recording + .add_chunk(&Arc::new( + builder.build().expect("chunk should be successfully built"), + )) + .expect("chunk should be successfully added"); + self.run_ok(); + } + + fn init_recording(&mut self) { + let app = self.state_mut(); + let store_hub = app.testonly_get_store_hub(); + + let store_info = StoreInfo::testing(); + let application_id = store_info.application_id().clone(); + let recording_store_id = store_info.store_id.clone(); + let mut recording_store = EntityDb::new(recording_store_id.clone()); + + recording_store.set_store_info(SetStoreInfo { + row_id: Tuid::new(), + info: store_info, + }); + { + // Set RecordingInfo: + recording_store + .set_recording_property( + EntityPath::properties(), + RecordingInfo::descriptor_name(), + &re_types::components::Name::from("Test recording"), + ) + .expect("Failed to set recording name"); + recording_store + .set_recording_property( + EntityPath::properties(), + RecordingInfo::descriptor_start_time(), + &re_types::components::Timestamp::now(), + ) + .expect("Failed to set recording start time"); + } + { + // Set some custom recording properties: + recording_store + .set_recording_property( + EntityPath::properties() / EntityPathPart::from("episode"), + ComponentDescriptor { + archetype: None, + component: "location".into(), + component_type: Some(re_types::components::Text::name()), + }, + &re_types::components::Text::from("Swallow Falls"), + ) + .expect("Failed to set recording property"); + recording_store + .set_recording_property( + EntityPath::properties() / EntityPathPart::from("episode"), + ComponentDescriptor { + archetype: None, + component: "weather".into(), + component_type: Some(re_types::components::Text::name()), + }, + &re_types::components::Text::from("Cloudy with meatballs"), + ) + .expect("Failed to set recording property"); + } + + let blueprint_id = StoreId::random(StoreKind::Blueprint, application_id); + let blueprint_store = EntityDb::new(blueprint_id.clone()); + + store_hub.insert_entity_db(recording_store); + store_hub.insert_entity_db(blueprint_store); + store_hub.set_active_recording_id(recording_store_id.clone()); + store_hub + .set_cloned_blueprint_active_for_app(&blueprint_id) + .expect("Failed to set blueprint as active"); + + app.command_sender.send_system(SystemCommand::SetSelection( + re_viewer_context::Item::StoreId(recording_store_id.clone()).into(), + )); + self.run_ok(); + } + + fn click_label(&mut self, label: &str) { + self.get_by_label(label).click(); + self.run_ok(); + } + + fn right_click_label(&mut self, label: &str) { + self.get_by_label(label).click_secondary(); + self.run_ok(); + } + + fn debug_viewer_state(&mut self) { + println!( + "Active recording: {:#?}", + self.state_mut().testonly_get_store_hub().active_recording() + ); + println!( + "Active blueprint: {:#?}", + self.state_mut().testonly_get_store_hub().active_blueprint() + ); + self.setup_viewport_blueprint(|_viewer_context, blueprint| { + println!("Blueprint view count: {}", blueprint.views.len()); + for id in blueprint.view_ids() { + println!("View id: {id}"); + } + println!( + "Display mode: {:?}", + _viewer_context.global_context.display_mode + ); + }); + } + + fn snapshot_app(&mut self, snapshot_name: &str) { + self.run_ok(); + // TODO(aedm): there is a nondeterministic font rendering issue. + self.snapshot_options( + snapshot_name, + &SnapshotOptions::new().failed_pixel_count_threshold(0), + ); + } +} diff --git a/tests/rust/re_integration_test/src/lib.rs b/tests/rust/re_integration_test/src/lib.rs index 9dbef55aed01..d6f64312ef99 100644 --- a/tests/rust/re_integration_test/src/lib.rs +++ b/tests/rust/re_integration_test/src/lib.rs @@ -1,7 +1,9 @@ //! Integration tests for rerun and the in memory server. +mod kittest_harness_ext; mod test_data; +pub use kittest_harness_ext::HarnessExt; use re_redap_client::{ClientConnectionError, ConnectionClient, ConnectionRegistry}; use re_server::ServerHandle; use re_uri::external::url::Host; diff --git a/tests/rust/re_integration_test/tests/basic_tests.rs b/tests/rust/re_integration_test/tests/basic_tests.rs new file mode 100644 index 000000000000..549f40cafa1e --- /dev/null +++ b/tests/rust/re_integration_test/tests/basic_tests.rs @@ -0,0 +1,35 @@ +use re_integration_test::HarnessExt as _; +use re_sdk::TimePoint; +use re_sdk::log::RowId; +use re_view_text_document::TextDocumentView; +use re_viewer::external::re_types; +use re_viewer::external::re_viewer_context::ViewClass as _; +use re_viewer::viewer_test_utils; +use re_viewport_blueprint::ViewBlueprint; + +#[tokio::test(flavor = "multi_thread")] +pub async fn test_single_text_document() { + let mut harness = viewer_test_utils::viewer_harness(); + harness.init_recording(); + harness.toggle_selection_panel(); + harness.snapshot("single_text_document_1"); + + // Log some data + harness.log_entity("txt/hello", |builder| { + builder.with_archetype( + RowId::new(), + TimePoint::STATIC, + &re_types::archetypes::TextDocument::new("Hello World!"), + ) + }); + + // Set up the viewport blueprint + harness.clear_current_blueprint(); + harness.setup_viewport_blueprint(|_viewer_context, blueprint| { + blueprint.add_view_at_root(ViewBlueprint::new_with_root_wildcard( + TextDocumentView::identifier(), + )); + }); + + harness.snapshot("single_text_document_2"); +} diff --git a/tests/rust/re_integration_test/tests/context_menu_test.rs b/tests/rust/re_integration_test/tests/context_menu_test.rs new file mode 100644 index 000000000000..7356155e3473 --- /dev/null +++ b/tests/rust/re_integration_test/tests/context_menu_test.rs @@ -0,0 +1,54 @@ +use re_integration_test::HarnessExt as _; +use re_sdk::TimePoint; +use re_sdk::log::RowId; +use re_view_text_document::TextDocumentView; +use re_viewer::external::re_types; +use re_viewer::external::re_viewer_context::{RecommendedView, ViewClass as _}; +use re_viewer::viewer_test_utils; +use re_viewport_blueprint::ViewBlueprint; + +#[tokio::test(flavor = "multi_thread")] +pub async fn test_stream_context_single_select() { + let mut harness = viewer_test_utils::viewer_harness(); + harness.init_recording(); + harness.toggle_selection_panel(); + + // Log some data + harness.log_entity("txt/hello/world", |builder| { + builder.with_archetype( + RowId::new(), + TimePoint::STATIC, + &re_types::archetypes::TextDocument::new("Hello World!"), + ) + }); + + // Set up the viewport blueprint + harness.clear_current_blueprint(); + + let text_document_view = ViewBlueprint::new( + TextDocumentView::identifier(), + RecommendedView { + origin: "/txt/hello".into(), + query_filter: "+ $origin/**".parse().unwrap(), + }, + ); + harness.setup_viewport_blueprint(|_viewer_context, blueprint| { + blueprint.add_view_at_root(text_document_view); + }); + + // Click streams tree items and check their context menu + harness.right_click_label("txt/"); + harness.snapshot("streams_context_single_select_1"); + + harness.click_label("Expand all"); + harness.snapshot("streams_context_single_select_2"); + + harness.right_click_label("world"); + harness.snapshot("streams_context_single_select_3"); + + harness.key_press(egui::Key::Escape); + harness.snapshot("streams_context_single_select_4"); + + harness.right_click_label("text"); + harness.snapshot("streams_context_single_select_5"); +} diff --git a/tests/rust/re_integration_test/tests/snapshots/single_text_document_1.png b/tests/rust/re_integration_test/tests/snapshots/single_text_document_1.png new file mode 100644 index 000000000000..1e9e81dfc8b6 --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/single_text_document_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22e7e9f0c0d92f22b01854c79aeb6438e69837ce83471e17ff0a0a385e302eda +size 58919 diff --git a/tests/rust/re_integration_test/tests/snapshots/single_text_document_2.png b/tests/rust/re_integration_test/tests/snapshots/single_text_document_2.png new file mode 100644 index 000000000000..562e9743f325 --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/single_text_document_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97bf66ad89a05b9c00b23aa7884a4a7dba1ea606c35f4efb33166361cd0a2b98 +size 63825 diff --git a/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_1.png b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_1.png new file mode 100644 index 000000000000..ad72dbd13e6b --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d73d0f8c53cc9156b071db6c58f5eba278ad903c0c95f63b59fce5da6c7b028 +size 74681 diff --git a/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_2.png b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_2.png new file mode 100644 index 000000000000..59d67b214212 --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b87bc2f3194ca2f2c492adb9aa60f4ff41dcf8a1ab131339dbdb97e483b277e +size 70141 diff --git a/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_3.png b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_3.png new file mode 100644 index 000000000000..a3c84791a5a8 --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41972f5a1c731d3cb9b8f8baee6b2d3030a61214c168bf42edba59d0787c960a +size 80836 diff --git a/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_4.png b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_4.png new file mode 100644 index 000000000000..a3c84791a5a8 --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41972f5a1c731d3cb9b8f8baee6b2d3030a61214c168bf42edba59d0787c960a +size 80836 diff --git a/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_5.png b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_5.png new file mode 100644 index 000000000000..8661063156c0 --- /dev/null +++ b/tests/rust/re_integration_test/tests/snapshots/streams_context_single_select_5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef1faf90d7c77a53d0e6f3087be4783caad42093b2117d63eb7792d793068871 +size 73998