diff --git a/src/app.rs b/src/app.rs index 21f874d64..1d75f3498 100644 --- a/src/app.rs +++ b/src/app.rs @@ -795,9 +795,6 @@ pub struct AppState { /// MCP configuration held until a required boot-time network selection succeeds. #[cfg(feature = "mcp")] mcp_server_pending_config: Option, - /// The egui secret prompt host, kept so newly-created (on-demand) network - /// contexts can have it installed before their backend is wired. - secret_prompt_host: Arc, /// Receives just-in-time passphrase requests enqueued by the egui secret /// prompt host. Drained once per frame in [`Self::update`]; the active /// request becomes [`Self::active_secret_prompt`]. @@ -1467,7 +1464,6 @@ impl AppState { mcp_app_context, #[cfg(feature = "mcp")] mcp_server_pending_config, - secret_prompt_host, secret_prompt_receiver, active_secret_prompt: None, prompt_was_blocking: false, @@ -1956,7 +1952,9 @@ impl AppState { return; } self.select_main_screen(root_screen_type); - self.active_root_screen_mut().refresh_on_arrival(); + let active_screen = self.active_root_screen_mut(); + active_screen.reset_to_root_view(); + active_screen.refresh_on_arrival(); self.current_app_context() .update_settings(root_screen_type) .ok(); @@ -2225,11 +2223,6 @@ impl App for AppState { context, .. } => { - // Install the egui prompt host before the new - // context's backend is wired, so its `SecretAccess` - // gets the interactive host rather than the headless - // default. - context.install_secret_prompt(Arc::clone(&self.secret_prompt_host)); self.network_contexts.insert(network, context); self.network_switch_pending = None; self.network_switch_banner.take_and_clear(); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e096c9b73..8f1186e01 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -1014,6 +1014,7 @@ impl AppContext { ) }) .ok_or(TaskError::NetworkContextCreationFailed { network })?; + new_ctx.install_secret_prompt(self.secret_prompt()); // Wire the freshly-built context's wallet backend and then start // chain sync. The old code called `start_spv()` on an unwired @@ -1277,6 +1278,70 @@ mod tests { backend.shutdown().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn switch_network_propagates_secret_prompt_before_wallet_backend_wiring() { + use crate::context::test_support::test_app_context; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::{IdentityKeyView, SecretPrompt}; + use platform_wallet_storage::secrets::SecretString; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + let identity_id = [0x91; 32]; + let target = PrivateKeyTarget::PrivateKeyOnMainIdentity; + let key_id = 7; + let key = [0xa5; 32]; + let password = "network-switch-password"; + let secret_store = ctx.secret_store(); + IdentityKeyView::new(&secret_store, identity_id) + .store_protected(&target, key_id, &key, &SecretString::new(password)) + .expect("store protected identity key"); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(password)])); + ctx.install_secret_prompt(Arc::clone(&prompt) as Arc); + + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + let result = ctx + .run_backend_task( + BackendTask::SwitchNetwork { + network: Network::Mainnet, + start_spv: true, + }, + sender, + ) + .await + .expect("switch network"); + + let BackendTaskSuccessResult::NetworkContextCreated { context, .. } = result else { + panic!("expected a newly created network context"); + }; + let backend = context.wallet_backend().expect("switched backend wired"); + let scope = SecretScope::IdentityKey { + identity_id, + target, + key_id, + }; + let resolved = backend + .secret_access() + .with_secret(&scope, |plaintext| { + Ok(plaintext.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("resolve protected key through switched backend"); + assert!( + resolved, + "the wired backend must resolve through the source interactive prompt" + ); + assert_eq!( + prompt.ask_count(), + 1, + "the switched backend must prompt once" + ); + backend.shutdown().await; + } + #[test] fn dapi_success_skips_availability_inspection() { let inspected = std::cell::Cell::new(false); diff --git a/src/context/mod.rs b/src/context/mod.rs index 70e215fd4..2f2611df1 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1146,9 +1146,13 @@ impl AppContext { /// [`NullSecretPrompt`], which surfaces `SecretPromptUnavailable` for any /// passphrase-protected scope. pub fn install_secret_prompt(&self, prompt: Arc) { - if let Ok(mut guard) = self.secret_prompt.0.lock() { - *guard = prompt; - } + let mut guard = self + .secret_prompt + .0 + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + *guard = prompt; + self.secret_prompt.0.clear_poison(); } /// The currently-installed secret-prompt host. Falls back to the headless @@ -1510,6 +1514,27 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla mod tests { use super::*; + #[test] + fn install_secret_prompt_recovers_poisoned_slot() { + use crate::context::test_support::test_app_context; + use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = ctx.secret_prompt.0.lock().expect("prompt slot lock"); + panic!("poison prompt slot"); + })); + assert!(poisoned.is_err(), "the prompt slot must be poisoned"); + + let prompt: Arc = Arc::new(TestPrompt::never()); + ctx.install_secret_prompt(Arc::clone(&prompt)); + assert!( + Arc::ptr_eq(&prompt, &ctx.secret_prompt()), + "install_secret_prompt must replace the host after lock poisoning" + ); + } + #[test] fn wallet_name_with_spaces_is_url_encoded() { let base = "http://127.0.0.1:9998"; diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 34d4cff09..e832ef034 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -259,3 +259,69 @@ impl AsyncTool for NetworkSwitch { } } } + +#[cfg(all(test, feature = "mcp"))] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::context::test_support::test_app_context; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::{IdentityKeyView, SecretPrompt}; + use platform_wallet_storage::secrets::SecretString; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn network_switch_tool_preserves_secret_prompt_identity() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + let identity_id = [0x92; 32]; + let target = PrivateKeyTarget::PrivateKeyOnMainIdentity; + let key_id = 8; + let key = [0xb6; 32]; + let password = "mcp-network-switch-password"; + let secret_store = ctx.secret_store(); + IdentityKeyView::new(&secret_store, identity_id) + .store_protected(&target, key_id, &key, &SecretString::new(password)) + .expect("store protected identity key"); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(password)])); + ctx.install_secret_prompt(Arc::clone(&prompt) as Arc); + let shared = Arc::new(arc_swap::ArcSwap::from(ctx)); + let service = DashMcpService::new_shared(shared); + + NetworkSwitch::invoke( + &service, + NetworkSwitchParams { + network: "mainnet".to_owned(), + }, + ) + .await + .expect("switch network through MCP"); + + let switched = service.tool_ctx().await.expect("switched context"); + let backend = switched.wallet_backend().expect("switched backend wired"); + let scope = SecretScope::IdentityKey { + identity_id, + target, + key_id, + }; + let resolved = backend + .secret_access() + .with_secret(&scope, |plaintext| { + Ok(plaintext.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("resolve protected key through MCP-switched backend"); + assert!( + resolved, + "the MCP-swapped backend must resolve through the source interactive prompt" + ); + assert_eq!( + prompt.ask_count(), + 1, + "the MCP-switched backend must prompt once" + ); + backend.shutdown().await; + } +} diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 451d70ef7..1d308c48a 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -790,6 +790,31 @@ impl KeyInfoScreen { } } + /// Build a key-info screen with the add-protection confirmation already open + /// when vault-backed protection is available, or show a warning when wallet + /// setup has not made protection available yet. + pub fn new_with_protection_prompt( + identity: QualifiedIdentity, + key: IdentityPublicKey, + private_key_data: Option<(PrivateKeyData, Option)>, + app_context: &Arc, + ) -> Self { + let mut screen = Self::new(identity, key, private_key_data, app_context); + let status = screen.compute_protection_status(); + if status == IdentityProtectionStatus::NoVaultKeys { + screen.protection_stage = ProtectionStage::Idle; + MessageBanner::set_global( + app_context.egui_ctx(), + "Password protection is not available yet. Wait for wallet setup to finish, then try again.", + MessageType::Warning, + ); + } else { + screen.protection_status = Some(status); + screen.open_add_confirm(); + } + screen + } + fn validate_and_store_private_key(&mut self) { // Convert the input string to bytes (hex decoding) let private_key_bytes = match hex::decode(self.private_key_input.text()) { @@ -1099,6 +1124,8 @@ impl KeyInfoScreen { egui::CollapsingHeader::new("Key Protection") .default_open(false) + // Keep the header open so an active protection form or dialog remains visible. + .open((self.protection_stage != ProtectionStage::Idle).then_some(true)) .show(ui, |ui| { let status_text = match status { IdentityProtectionStatus::Unprotected => { diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index f5e3421a4..311b34535 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -236,6 +236,11 @@ pub enum DetailOutcome { Forward(Box), } +enum KeyInfoOpenMode { + Normal, + WithProtectionPrompt, +} + /// Masternode/evonode detail view state. pub struct MasternodeDetailView { app_context: Arc, @@ -659,7 +664,7 @@ impl MasternodeDetailView { && let Some((target, key)) = self.first_protectable_key() && ui.button("Add password protection…").clicked() { - action = Some(self.open_key_info(target, &key)); + action = Some(self.open_key_info_with_protection_prompt(target, &key)); } action } @@ -701,17 +706,40 @@ impl MasternodeDetailView { &self, target: PrivateKeyTarget, key: &dash_sdk::platform::IdentityPublicKey, + ) -> AppAction { + self.open_key_info_with_mode(target, key, KeyInfoOpenMode::Normal) + } + + /// Open `KeyInfoScreen` directly in the add-protection confirmation flow. + fn open_key_info_with_protection_prompt( + &self, + target: PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, + ) -> AppAction { + self.open_key_info_with_mode(target, key, KeyInfoOpenMode::WithProtectionPrompt) + } + + fn open_key_info_with_mode( + &self, + target: PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, + mode: KeyInfoOpenMode, ) -> AppAction { let holding = self .identity .private_keys .get_cloned_private_key_data_and_wallet_info(&(target, key.id())); - AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - holding, - &self.app_context, - ))) + let identity = self.identity.clone(); + let key = key.clone(); + let screen = match mode { + KeyInfoOpenMode::Normal => { + KeyInfoScreen::new(identity, key, holding, &self.app_context) + } + KeyInfoOpenMode::WithProtectionPrompt => { + KeyInfoScreen::new_with_protection_prompt(identity, key, holding, &self.app_context) + } + }; + AppAction::AddScreen(Screen::KeyInfoScreen(screen)) } /// Render the collapsible DPNS voting section (collapsed by default, diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index f658293f1..8a3c174b4 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -516,6 +516,12 @@ impl ScreenLike for MasternodesScreen { self.reconcile_pending_load(); } + fn reset_to_root_view(&mut self) { + if !matches!(self.view, MasternodesView::Load(_)) { + self.view = MasternodesView::List; + } + } + /// Drop every secret the open view holds — the load form's keys and /// encryption password, the detail view's unsubmitted voting key. This screen /// is a root screen: it lives for the whole process, so nothing else would @@ -874,6 +880,12 @@ mod tests { "submit must re-enable after a failed load" ); + screen.reset_to_root_view(); + assert!( + matches!(screen.view, MasternodesView::Load(_)), + "root navigation must preserve a failed load form for correction" + ); + // Resubmit, then succeed: the form closes and drops back to the list. submit_load(&mut screen, target); claim_dispatched_load(&ctx, &screen).loaded(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bd0496b6b..82417da35 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -770,6 +770,12 @@ pub trait ScreenLike { self.refresh() } + /// Reset a persistent root screen to the view selected by its navigation entry. + /// Called only by `AppState::set_main_screen` on root navigation, not by + /// [`refresh_on_arrival`](ScreenLike::refresh_on_arrival) or [`on_leave`](ScreenLike::on_leave), + /// so returning from a pushed sub-screen preserves the current sub-view. + fn reset_to_root_view(&mut self) {} + /// Called by `AppState` when this root screen stops being the selected one. /// /// The counterpart of [`refresh_on_arrival`](ScreenLike::refresh_on_arrival). @@ -1087,6 +1093,10 @@ impl ScreenLike for Screen { delegate_to_screen!(self, screen => screen.refresh_on_arrival()) } + fn reset_to_root_view(&mut self) { + delegate_to_screen!(self, screen => screen.reset_to_root_view()) + } + fn on_leave(&mut self) { delegate_to_screen!(self, screen => screen.on_leave()) } diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 3439153cc..393285c74 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -3,15 +3,21 @@ use crate::support::{mount_app, with_isolated_data_dir}; use dash_evo_tool::context::AppContext; -use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; -use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; +use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use dash_evo_tool::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, +}; use dash_evo_tool::model::user_role::UserRole; use dash_evo_tool::ui::{RootScreenType, ScreenLike}; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::platform::Identifier; -use egui_kittest::kittest::Queryable; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use egui::accesskit::{Role, Toggled}; +use egui_kittest::kittest::{NodeT, Queryable}; use std::collections::BTreeMap; use std::sync::Arc; @@ -134,6 +140,47 @@ fn seed_node_with_voter_key(app_context: &Arc, byte: u8, alias: &str .expect("seed node-with-voter-key insert"); } +/// Seed a masternode with one locally held key. Insertion migrates the clear +/// key into the keyless vault, matching a loaded node with no password. +fn seed_node_with_unprotected_held_key(app_context: &Arc, byte: u8, alias: &str) { + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(1, Some(1), pv); + let identity = Identity::new_with_id_and_keys( + Identifier::from([byte; 32]), + BTreeMap::from([(key.id(), key.clone())]), + pv, + ) + .expect("masternode identity with key"); + let private_keys = KeyStorage { + private_keys: BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear([byte; 32]), + ), + )]), + }; + let node_qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&node_qi, &None) + .expect("seed node-with-unprotected-key insert"); +} + /// TC-FR1-01…04 — the Masternodes nav entry is absent below the Power role and /// present at Power or above. Raising the role flips the gate; the nav rail /// re-evaluates the per-entry `FeatureGate::Masternodes` skip each frame. @@ -787,3 +834,257 @@ fn manage_keys_button_opens_key_info_screen() { ); }); } + +/// Clicking the aggregate protection CTA opens `KeyInfoScreen` with its +/// confirmation dialog already visible, without a second click in that screen. +#[test] +fn add_password_protection_opens_confirmation_dialog() { + use dash_evo_tool::ui::Screen; + + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node_with_unprotected_held_key(&app_context, 0x72, "mn-protect-01"); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-protect-01").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("Add password protection…").is_some(), + "an unprotected vault key must offer the aggregate protection action" + ); + + harness.get_by_label("Add password protection…").click(); + harness.run_steps(3); + + assert!( + matches!( + harness.state().screen_stack.last(), + Some(Screen::KeyInfoScreen(_)) + ), + "the aggregate protection action must push KeyInfoScreen" + ); + assert!( + harness + .query_by_label("Protect this identity's keys with a password?") + .is_some(), + "the protection confirmation dialog must be visible immediately" + ); + assert!( + harness.query_by_label("Yes, add protection").is_some(), + "the visible dialog must expose its confirmation action" + ); + }); +} + +#[test] +fn left_nav_return_to_masternodes_resets_detail_to_list() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node( + &app_context, + 0x72, + "mn-nav-reset-01", + IdentityType::Masternode, + ); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-nav-reset-01").click(); + harness.run_steps(3); + assert!(harness.query_by_label("‹ All masternodes").is_some()); + + harness + .get_by_role_and_label(Role::Button, "Wallets") + .click(); + harness.run_steps(3); + harness + .get_by_role_and_label(Role::Button, "Masternodes") + .click(); + harness.run_steps(3); + + assert!( + harness.query_by_label("Open mn-nav-reset-01").is_some(), + "returning through the left nav must show the masternode list" + ); + assert!(harness.query_by_label("‹ All masternodes").is_none()); + }); +} + +#[test] +fn active_masternodes_left_nav_resets_detail_to_list() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node( + &app_context, + 0x74, + "mn-active-nav-reset-01", + IdentityType::Masternode, + ); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-active-nav-reset-01").click(); + harness.run_steps(3); + assert!(harness.query_by_label("‹ All masternodes").is_some()); + + harness + .get_by_role_and_label(Role::Button, "Masternodes") + .click(); + harness.run_steps(3); + + assert!( + harness + .query_by_label("Open mn-active-nav-reset-01") + .is_some(), + "clicking the already-active Masternodes nav entry must show the list" + ); + assert!(harness.query_by_label("‹ All masternodes").is_none()); + }); +} + +#[test] +fn left_nav_return_preserves_pending_masternode_load_form_fields() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(1) + .build() + .expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + let (blocking_release, blocking_wait) = std::sync::mpsc::channel::<()>(); + let _blocking_task = tokio::task::spawn_blocking(move || blocking_wait.recv()); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Load a masternode").click(); + harness.set_size(egui::vec2(1280.0, 1200.0)); + harness.run_steps(3); + + harness.get_by_label("Evonode").click(); + harness.run_steps(2); + assert_eq!( + harness.get_by_label("Evonode").accesskit_node().toggled(), + Some(Toggled::True), + "the non-default node type must be selected before submission" + ); + + let identity_id = Identifier::from([0x75; 32]); + let pro_tx_hash = identity_id.to_string(Encoding::Hex); + harness + .query_all_by_role(Role::TextInput) + .next() + .expect("ProTxHash input") + .focus(); + harness.event(egui::Event::Text(pro_tx_hash.clone())); + harness.step(); + + let alias = "mn-pending-nav-01"; + harness + .query_all_by_role(Role::TextInput) + .nth(1) + .expect("alias input") + .focus(); + harness.event(egui::Event::Text(alias.to_owned())); + harness.step(); + assert!( + harness.query_all_by_value(&pro_tx_hash).count() >= 1, + "the ProTxHash must be entered before submission" + ); + assert!( + harness.query_all_by_value(alias).count() >= 1, + "the alias must be entered before submission" + ); + + harness.get_by_label("Load masternode").click(); + harness.step(); + assert!( + app_context + .mark_identity_load_submitted(identity_id) + .is_none(), + "the load must be pending before navigation" + ); + + harness + .get_by_role_and_label(Role::Button, "Wallets") + .click(); + harness.run_steps(3); + harness + .get_by_role_and_label(Role::Button, "Masternodes") + .click(); + harness.run_steps(3); + + assert!( + harness.query_by_label("ProTxHash").is_some(), + "returning while a load is pending must keep the load form open" + ); + assert!( + harness.query_all_by_value(&pro_tx_hash).count() >= 1, + "the pending load must retain its entered ProTxHash" + ); + assert!( + harness.query_all_by_value(alias).count() >= 1, + "the pending load must retain its entered alias" + ); + assert_eq!( + harness.get_by_label("Evonode").accesskit_node().toggled(), + Some(Toggled::True), + "the pending load must retain its entered node type" + ); + + drop(blocking_release); + for _ in 0..10 { + harness.step(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); +} + +#[test] +fn go_to_main_screen_from_key_info_preserves_masternode_detail() { + use dash_evo_tool::ui::Screen; + + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node_with_voter_key(&app_context, 0x73, "mn-nav-back-01"); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-nav-back-01").click(); + harness.run_steps(3); + harness.get_by_label("Voting key ›").click(); + harness.run_steps(3); + assert!(matches!( + harness.state().screen_stack.last(), + Some(Screen::KeyInfoScreen(_)) + )); + + harness + .query_all_by_role_and_label(Role::Button, "Identities") + .min_by(|left, right| left.rect().top().total_cmp(&right.rect().top())) + .expect("Key Info breadcrumb") + .click(); + harness.run_steps(3); + + assert!(harness.state().screen_stack.is_empty()); + assert!( + harness.query_by_label("‹ All masternodes").is_some(), + "returning from Key Info must preserve the open detail view" + ); + assert!(harness.query_by_label("Open mn-nav-back-01").is_none()); + }); +}