From 36f182b5fbcd8a6afd7ad1ae3bb2c9f8f6acf93d Mon Sep 17 00:00:00 2001 From: Patrick Mill Date: Wed, 4 Nov 2026 11:43:50 -0500 Subject: [PATCH] refactor(tui): centralize library selection and preserve saved identity --- docs/tui.md | 4 + romm-api/src/config.rs | 63 +++++++ romm-tui/CHANGELOG.md | 6 + romm-tui/src/tui/app/background/tasks.rs | 4 +- romm-tui/src/tui/app/handlers/library.rs | 10 +- romm-tui/src/tui/app/handlers/navigation.rs | 13 +- romm-tui/src/tui/app/mod.rs | 15 ++ romm-tui/src/tui/app/rom_load.rs | 18 +- romm-tui/src/tui/app/tests.rs | 55 ++++++ .../src/tui/screens/library_browse/cache.rs | 154 ++++++++------- .../src/tui/screens/library_browse/tests.rs | 178 +++++++++++++++++- 11 files changed, 428 insertions(+), 92 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 6820b04..c4fcb8a 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -56,6 +56,8 @@ Built-in theme IDs include `terminal`, `catppuccin`, `dracula`, `nord`, `tokyo-n Choosing **Library** from the main menu loads a compact on-disk snapshot of platforms and merged collections (if present) so the list renders without waiting for the network. A background task refetches endpoints, updates the UI, and writes a fresh snapshot. +The last selected console or collection is restored across launches when that row still exists; otherwise the TUI prefers a row with a complete ROM cache hit, then falls back to the first list entry. + Full ROM lists use the on-disk ROM cache with **stale-while-revalidate**: if a complete cached list exists for the selected console or collection, the Games pane paints it immediately (even when `rom_count` has changed). A background fetch refreshes the list when counts diverge; the pane is not cleared to empty while cache data exists. OpenAPI compatibility and heartbeat version are loaded from local cache/embedded first so the TUI can paint before contacting the server; a background refresh updates feature gates and server version after first paint. @@ -68,6 +70,8 @@ Override snapshot path with `ROMM_LIBRARY_METADATA_SNAPSHOT_PATH` (default: next This section describes the TUI implementation for contributors. Read alongside `cargo doc -p romm-tui --open` and [architecture.md](architecture.md). +Library selection interpretation lives in the library-browse module: current rows and collection prefetch reuse position-based cache identity and count lookup. ROM request targets and persisted selection derive from that identity; App callers ask the screen for the selected ROM request rather than dispatching on console versus collection. + ### Event loop The heart of the TUI lives in `romm_tui::tui::app::App::run`: diff --git a/romm-api/src/config.rs b/romm-api/src/config.rs index 0ffbb04..5dafbda 100644 --- a/romm-api/src/config.rs +++ b/romm-api/src/config.rs @@ -150,6 +150,33 @@ fn default_game_detail_cover_panel_width() -> u16 { GAME_DETAIL_COVER_PANEL_WIDTH_DEFAULT } +/// Library left-pane grouping persisted across TUI launches. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LibrarySubsectionKind { + /// Consoles / platforms list. + Console, + /// Collections list (manual, smart, or virtual). + Collection, +} + +/// Identity of the selected library list row. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LibrarySelectionKey { + Platform { id: u64 }, + Collection { id: u64 }, + SmartCollection { id: u64 }, + VirtualCollection { id: String }, +} + +/// Last library list selection restored on TUI open. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LibrarySelectionState { + pub subsection: LibrarySubsectionKind, + pub key: LibrarySelectionKey, +} + /// TUI panel layout preferences (library split and game detail cover width). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TuiLayoutConfig { @@ -159,6 +186,9 @@ pub struct TuiLayoutConfig { /// Game detail: cover column width in terminal cells. #[serde(default = "default_game_detail_cover_panel_width")] pub game_detail_cover_panel_width: u16, + /// Last console/collection selection (restored on library open when still present). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_selection: Option, } impl Default for TuiLayoutConfig { @@ -166,6 +196,7 @@ impl Default for TuiLayoutConfig { Self { library_left_panel_percent: LIBRARY_LEFT_PANEL_PERCENT_DEFAULT, game_detail_cover_panel_width: GAME_DETAIL_COVER_PANEL_WIDTH_DEFAULT, + library_selection: None, } } } @@ -182,6 +213,7 @@ impl TuiLayoutConfig { GAME_DETAIL_COVER_PANEL_WIDTH_MIN, GAME_DETAIL_COVER_PANEL_WIDTH_MAX, ), + library_selection: self.library_selection, } } } @@ -1293,6 +1325,7 @@ mod tests { let cfg = TuiLayoutConfig { library_left_panel_percent: 5, game_detail_cover_panel_width: 999, + library_selection: None, } .normalized(); assert_eq!( @@ -1305,6 +1338,36 @@ mod tests { ); } + #[test] + fn config_library_selection_round_trips() { + let json = r#"{ + "base_url":"http://x", + "download_dir":"/tmp", + "use_https":false, + "tui_layout":{ + "library_left_panel_percent":30, + "game_detail_cover_panel_width":36, + "library_selection":{ + "subsection":"console", + "key":{"type":"platform","id":42} + } + } + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + let sel = cfg + .tui_layout + .library_selection + .as_ref() + .expect("selection"); + assert_eq!(sel.subsection, LibrarySubsectionKind::Console); + assert_eq!(sel.key, LibrarySelectionKey::Platform { id: 42 }); + let out = serde_json::to_value(&cfg.tui_layout).unwrap(); + assert_eq!( + out["library_selection"]["key"]["type"], + serde_json::json!("platform") + ); + } + fn clear_auth_env() { for key in [ "API_BASE_URL", diff --git a/romm-tui/CHANGELOG.md b/romm-tui/CHANGELOG.md index 0e6aed9..8ce831a 100644 --- a/romm-tui/CHANGELOG.md +++ b/romm-tui/CHANGELOG.md @@ -7,6 +7,12 @@ Entries before the workspace split (1.0.0) are filtered from the unified monolit The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Consolidate library selection identity, count, and ROM request interpretation without changing browsing, prefetch, or saved-selection behavior. + ## [1.4.2](https://github.com/patricksmill/romm-cli/compare/romm-tui-v1.4.1...romm-tui-v1.4.2) (2026-09-28) diff --git a/romm-tui/src/tui/app/background/tasks.rs b/romm-tui/src/tui/app/background/tasks.rs index 18b9768..320e560 100644 --- a/romm-tui/src/tui/app/background/tasks.rs +++ b/romm-tui/src/tui/app/background/tasks.rs @@ -739,7 +739,7 @@ impl super::super::App { let selection_reload = if selection_changed && lib.list_len() > 0 { let key = lib.cache_key(); let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); Some((key, req, expected, "refresh_selection")) } else { None @@ -747,7 +747,7 @@ impl super::super::App { let post_scan_reload = if force_reload && lib.list_len() > 0 && !selection_changed { let key = lib.cache_key(); let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); Some((key, req, expected, "post_scan_reload")) } else { None diff --git a/romm-tui/src/tui/app/handlers/library.rs b/romm-tui/src/tui/app/handlers/library.rs index 7d4a090..2a0968e 100644 --- a/romm-tui/src/tui/app/handlers/library.rs +++ b/romm-tui/src/tui/app/handlers/library.rs @@ -102,11 +102,12 @@ impl App { let new_key = lib.cache_key(); if old_key != new_key && lib.list_len() > 0 { let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); pending_rom_load = Some((new_key, req, expected, "search_filter")); } if let Some((key, req, expected, context)) = pending_rom_load { self.apply_selection_rom_load(key, req, expected, context); + self.persist_library_selection_if_changed(); } return Ok(false); } @@ -130,7 +131,7 @@ impl App { if lib.list_len() > 0 { let key = lib.cache_key(); let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); pending_rom_load = Some((key, req, expected, "list_move_up")); if lib.subsection == crate::tui::screens::library_browse::LibrarySubsection::ByCollection @@ -149,7 +150,7 @@ impl App { if lib.list_len() > 0 { let key = lib.cache_key(); let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); pending_rom_load = Some((key, req, expected, "list_move_down")); if lib.subsection == crate::tui::screens::library_browse::LibrarySubsection::ByCollection @@ -196,7 +197,7 @@ impl App { if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 { let key = lib.cache_key(); let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); pending_rom_load = Some((key, req, expected, "switch_subsection")); } if lib.subsection @@ -224,6 +225,7 @@ impl App { } if let Some((key, req, expected, context)) = pending_rom_load { self.apply_selection_rom_load(key, req, expected, context); + self.persist_library_selection_if_changed(); } if prefetch_collections { self.queue_collection_prefetches_from_screen(1, "library_selection"); diff --git a/romm-tui/src/tui/app/handlers/navigation.rs b/romm-tui/src/tui/app/handlers/navigation.rs index 136c8c5..e4547f2 100644 --- a/romm-tui/src/tui/app/handlers/navigation.rs +++ b/romm-tui/src/tui/app/handlers/navigation.rs @@ -42,21 +42,26 @@ impl App { } else if lib.list_len() == 0 { lib.set_metadata_footer(Some("Loading library metadata…".into())); } - lib.prefer_first_complete_cache_hit(&self.rom_cache); + let restored = lib.restore_selection(&self.config.tui_layout.library_selection); + if !restored { + lib.prefer_first_complete_cache_hit(&self.rom_cache); + } self.screen = AppScreen::LibraryBrowse(Box::new(lib)); if let AppScreen::LibraryBrowse(ref lib) = self.screen { if lib.list_len() > 0 { let key = lib.cache_key(); let expected = lib.expected_rom_count(); - let req = Self::selected_rom_request_for_library(lib); + let req = lib.get_roms_request(); self.apply_selection_rom_load(key, req, expected, "startup_first_selection"); } } + self.persist_library_selection_if_changed(); self.spawn_library_metadata_refresh(); tracing::debug!( - "library-open latency_ms={} snapshot_hit={}", + "library-open latency_ms={} snapshot_hit={} selection_restored={}", start.elapsed().as_millis(), - from_disk + from_disk, + restored ); } diff --git a/romm-tui/src/tui/app/mod.rs b/romm-tui/src/tui/app/mod.rs index fcbb4c9..55f1acf 100644 --- a/romm-tui/src/tui/app/mod.rs +++ b/romm-tui/src/tui/app/mod.rs @@ -360,6 +360,21 @@ impl App { } } + /// Update and persist library selection when the list identity changes. + pub(in crate::tui::app) fn persist_library_selection_if_changed(&mut self) { + let AppScreen::LibraryBrowse(ref lib) = self.screen else { + return; + }; + let Some(next) = lib.selection_state() else { + return; + }; + if self.config.tui_layout.library_selection.as_ref() == Some(&next) { + return; + } + self.config.tui_layout.library_selection = Some(next); + self.persist_tui_layout(); + } + #[cfg(test)] pub(crate) fn theme_id(&self) -> &str { self.theme.id() diff --git a/romm-tui/src/tui/app/rom_load.rs b/romm-tui/src/tui/app/rom_load.rs index 796d01c..a0c783a 100644 --- a/romm-tui/src/tui/app/rom_load.rs +++ b/romm-tui/src/tui/app/rom_load.rs @@ -157,27 +157,11 @@ impl super::App { lib.set_rom_loading(false); return; } - ( - lib.cache_key(), - Self::selected_rom_request_for_library(lib), - expected, - ) + (lib.cache_key(), lib.get_roms_request(), expected) }; self.queue_primary_rom_load(pending.0, pending.1, pending.2, context); } - pub(in crate::tui::app) fn selected_rom_request_for_library( - lib: &crate::tui::screens::library_browse::LibraryBrowseScreen, - ) -> Option { - match lib.subsection { - crate::tui::screens::library_browse::LibrarySubsection::ByConsole => { - lib.get_roms_request_platform() - } - crate::tui::screens::library_browse::LibrarySubsection::ByCollection => { - lib.get_roms_request_collection() - } - } - } pub(in crate::tui::app) fn queue_collection_prefetches_from_screen( &mut self, radius: usize, diff --git a/romm-tui/src/tui/app/tests.rs b/romm-tui/src/tui/app/tests.rs index 8409762..2e8c60b 100644 --- a/romm-tui/src/tui/app/tests.rs +++ b/romm-tui/src/tui/app/tests.rs @@ -515,6 +515,61 @@ fn open_library_browse_prefers_first_complete_cache_hit() { assert_eq!(lib.cache_key(), Some(RomCacheKey::Platform(9002))); } +#[test] +fn open_library_browse_restores_saved_selection_over_cache_prefer() { + use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind}; + let mut app = app_with_library(vec![]); + let list = RomList { + items: vec![rom_fixture()], + total: 1, + limit: 50, + offset: 0, + }; + app.rom_cache + .insert(RomCacheKey::Platform(9301), list.clone(), 1); + app.rom_cache.insert(RomCacheKey::Platform(9302), list, 1); + app.config.tui_layout.library_selection = Some(LibrarySelectionState { + subsection: LibrarySubsectionKind::Console, + key: LibrarySelectionKey::Platform { id: 9302 }, + }); + + if let AppScreen::LibraryBrowse(ref mut lib) = app.screen { + **lib = LibraryBrowseScreen::new( + vec![platform(9301, "FirstCached", 1), platform(9302, "Saved", 1)], + vec![], + LIBRARY_LEFT_PANEL_PERCENT_DEFAULT, + ); + let restored = lib.restore_selection(&app.config.tui_layout.library_selection); + assert!(restored); + if !restored { + lib.prefer_first_complete_cache_hit(&app.rom_cache); + } + assert_eq!(lib.cache_key(), Some(RomCacheKey::Platform(9302))); + } else { + panic!("expected library"); + } +} + +#[tokio::test] +async fn list_move_persists_library_selection_identity() { + use romm_api::config::{LibrarySelectionKey, LibrarySubsectionKind}; + let mut app = app_with_library(vec![platform(9401, "NES", 1), platform(9402, "SNES", 1)]); + assert!(app.config.tui_layout.library_selection.is_none()); + + assert!(!app + .handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty())) + .await + .expect("key handled")); + + let sel = app + .config + .tui_layout + .library_selection + .expect("selection should persist after list move"); + assert_eq!(sel.subsection, LibrarySubsectionKind::Console); + assert_eq!(sel.key, LibrarySelectionKey::Platform { id: 9402 }); +} + #[test] fn ctrl_c_is_treated_as_force_quit() { let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); diff --git a/romm-tui/src/tui/screens/library_browse/cache.rs b/romm-tui/src/tui/screens/library_browse/cache.rs index f5aff74..df63f41 100644 --- a/romm-tui/src/tui/screens/library_browse/cache.rs +++ b/romm-tui/src/tui/screens/library_browse/cache.rs @@ -55,40 +55,24 @@ impl LibraryBrowseScreen { subsection: LibrarySubsection, source_idx: usize, ) -> Option { - let count = self - .expected_rom_count_for_position(subsection, source_idx) - .min(20000); - if count == 0 { + if self.expected_rom_count_for_position(subsection, source_idx) == 0 { return None; } - match subsection { - LibrarySubsection::ByConsole => self.platforms.get(source_idx).map(|p| GetRoms { - platform_id: Some(p.id), - limit: Some(50), - ..Default::default() - }), - LibrarySubsection::ByCollection => self.collections.get(source_idx).map(|c| { - if c.is_virtual { - GetRoms { - virtual_collection_id: c.virtual_id.clone(), - limit: Some(50), - ..Default::default() - } - } else if c.is_smart { - GetRoms { - smart_collection_id: Some(c.id), - limit: Some(50), - ..Default::default() - } - } else { - GetRoms { - collection_id: Some(c.id), - limit: Some(50), - ..Default::default() - } - } - }), + let mut request = GetRoms { + limit: Some(50), + ..Default::default() + }; + match self.cache_key_for_position(subsection, source_idx)? { + RomCacheKey::Platform(id) => request.platform_id = Some(id), + RomCacheKey::Collection(id) => request.collection_id = Some(id), + RomCacheKey::SmartCollection(id) => request.smart_collection_id = Some(id), + RomCacheKey::VirtualCollection(id) => { + // Cache identity defaults a missing ID to ""; the request must retain None. + request.virtual_collection_id = + self.collections[source_idx].virtual_id.as_ref().map(|_| id); + } } + Some(request) } /// Replace metadata while preserving subsection and selection when possible. @@ -180,35 +164,14 @@ impl LibraryBrowseScreen { out } pub fn cache_key(&self) -> Option { - match self.subsection { - LibrarySubsection::ByConsole => self.selected_platform_id().map(RomCacheKey::Platform), - LibrarySubsection::ByCollection => self - .selected_list_source_index() - .and_then(|i| self.collections.get(i)) - .map(|c| { - if c.is_virtual { - RomCacheKey::VirtualCollection(c.virtual_id.clone().unwrap_or_default()) - } else if c.is_smart { - RomCacheKey::SmartCollection(c.id) - } else { - RomCacheKey::Collection(c.id) - } - }), - } + self.selected_list_source_index() + .and_then(|i| self.cache_key_for_position(self.subsection, i)) } pub fn expected_rom_count(&self) -> u64 { - match self.subsection { - LibrarySubsection::ByConsole => self - .selected_list_source_index() - .and_then(|i| self.platforms.get(i).map(|p| p.rom_count)) - .unwrap_or(0), - LibrarySubsection::ByCollection => self - .selected_list_source_index() - .and_then(|i| self.collections.get(i)) - .and_then(|c| c.rom_count) - .unwrap_or(0), - } + self.selected_list_source_index() + .map(|i| self.expected_rom_count_for_position(self.subsection, i)) + .unwrap_or(0) } /// Prefer the first visible list row that has a complete ROM cache entry. @@ -225,16 +188,79 @@ impl LibraryBrowseScreen { } } - pub fn get_roms_request_platform(&self) -> Option { - self.selected_list_source_index() - .and_then(|i| self.get_roms_request_for_position(LibrarySubsection::ByConsole, i)) + /// Current list selection as a persistable config value. + pub fn selection_state(&self) -> Option { + use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind}; + let key = match self.cache_key()? { + RomCacheKey::Platform(id) => LibrarySelectionKey::Platform { id }, + RomCacheKey::Collection(id) => LibrarySelectionKey::Collection { id }, + RomCacheKey::SmartCollection(id) => LibrarySelectionKey::SmartCollection { id }, + RomCacheKey::VirtualCollection(id) => LibrarySelectionKey::VirtualCollection { id }, + }; + let subsection = match self.subsection { + LibrarySubsection::ByConsole => LibrarySubsectionKind::Console, + LibrarySubsection::ByCollection => LibrarySubsectionKind::Collection, + }; + Some(LibrarySelectionState { subsection, key }) } - pub fn get_roms_request_collection(&self) -> Option { - if self.subsection != LibrarySubsection::ByCollection { - return None; + /// Restore a previously persisted selection when that row still exists. + /// + /// Returns `true` when the selection was applied. + pub fn restore_selection( + &mut self, + saved: &Option, + ) -> bool { + use romm_api::config::{LibrarySelectionKey, LibrarySubsectionKind}; + let Some(saved) = saved.as_ref() else { + return false; + }; + let subsection = match saved.subsection { + LibrarySubsectionKind::Console => LibrarySubsection::ByConsole, + LibrarySubsectionKind::Collection => LibrarySubsection::ByCollection, + }; + self.subsection = subsection; + self.list_search.clear(); + self.view_mode = LibraryViewMode::List; + self.roms = None; + self.rom_loading = false; + + let source_idx = match (&saved.key, subsection) { + (LibrarySelectionKey::Platform { id }, LibrarySubsection::ByConsole) => { + self.platforms.iter().position(|p| p.id == *id) + } + (LibrarySelectionKey::Collection { id }, LibrarySubsection::ByCollection) => self + .collections + .iter() + .position(|c| !c.is_virtual && !c.is_smart && c.id == *id), + (LibrarySelectionKey::SmartCollection { id }, LibrarySubsection::ByCollection) => self + .collections + .iter() + .position(|c| c.is_smart && !c.is_virtual && c.id == *id), + (LibrarySelectionKey::VirtualCollection { id }, LibrarySubsection::ByCollection) => { + self.collections + .iter() + .position(|c| c.is_virtual && c.virtual_id.as_deref() == Some(id.as_str())) + } + _ => None, + }; + let Some(source_idx) = source_idx else { + self.subsection = LibrarySubsection::ByConsole; + self.list_index = 0; + return false; + }; + let visible = self.visible_list_source_indices(); + if let Some(list_idx) = visible.iter().position(|&s| s == source_idx) { + self.list_index = list_idx; + true + } else { + self.list_index = 0; + false } + } + + pub fn get_roms_request(&self) -> Option { self.selected_list_source_index() - .and_then(|i| self.get_roms_request_for_position(LibrarySubsection::ByCollection, i)) + .and_then(|i| self.get_roms_request_for_position(self.subsection, i)) } } diff --git a/romm-tui/src/tui/screens/library_browse/tests.rs b/romm-tui/src/tui/screens/library_browse/tests.rs index c2df68b..4fa0924 100644 --- a/romm-tui/src/tui/screens/library_browse/tests.rs +++ b/romm-tui/src/tui/screens/library_browse/tests.rs @@ -223,7 +223,7 @@ fn zero_rom_platform_builds_no_rom_request() { LEFT_PANEL_PERCENT_DEFAULT, ); assert!( - s.get_roms_request_platform().is_none(), + s.get_roms_request().is_none(), "zero-rom platform should not produce ROM API request" ); } @@ -294,6 +294,51 @@ fn adjust_left_panel_percent_clamps_at_bounds() { assert_eq!(s.left_panel_percent, 50); } +#[test] +fn restore_selection_selects_matching_platform() { + use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind}; + let mut s = LibraryBrowseScreen::new( + vec![ + platform(1, "NES", 10), + platform(2, "SNES", 20), + platform(3, "N64", 30), + ], + vec![], + LEFT_PANEL_PERCENT_DEFAULT, + ); + let saved = Some(LibrarySelectionState { + subsection: LibrarySubsectionKind::Console, + key: LibrarySelectionKey::Platform { id: 3 }, + }); + assert!(s.restore_selection(&saved)); + assert_eq!(s.list_index, 2); + assert_eq!( + s.cache_key(), + Some(romm_api::core::cache::RomCacheKey::Platform(3)) + ); + assert_eq!( + s.selection_state(), + saved, + "selection_state should round-trip the restored identity" + ); +} + +#[test] +fn restore_selection_missing_key_returns_false() { + use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind}; + let mut s = LibraryBrowseScreen::new( + vec![platform(1, "NES", 10)], + vec![], + LEFT_PANEL_PERCENT_DEFAULT, + ); + let saved = Some(LibrarySelectionState { + subsection: LibrarySubsectionKind::Console, + key: LibrarySelectionKey::Platform { id: 99 }, + }); + assert!(!s.restore_selection(&saved)); + assert_eq!(s.list_index, 0); +} + #[test] fn rom_row_not_highlighted_while_browsing_consoles() { let mut s = LibraryBrowseScreen::new(vec![], vec![], LEFT_PANEL_PERCENT_DEFAULT); @@ -314,3 +359,134 @@ fn rom_row_highlighted_only_in_games_mode() { assert!(!s.rom_row_highlighted(0)); assert!(s.rom_row_highlighted(2)); } + +#[test] +fn collection_selection_preserves_kind_when_ids_overlap() { + use super::LibrarySubsection; + use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind}; + use romm_api::core::cache::RomCacheKey; + use romm_api::types::Collection; + + let collections: Vec = [ + json!({"id": 7, "name": "Manual", "rom_count": 3}), + json!({"id": 7, "name": "Smart", "rom_count": 4, "is_smart": true}), + json!({"id": 7, "name": "Virtual", "rom_count": 5, + "is_smart": true, "is_virtual": true, "virtual_id": "recent"}), + ] + .into_iter() + .map(|value| serde_json::from_value(value).unwrap()) + .collect(); + let mut screen = LibraryBrowseScreen::new(vec![], collections, LEFT_PANEL_PERCENT_DEFAULT); + screen.subsection = LibrarySubsection::ByCollection; + for (index, key, saved_key, count, target) in [ + ( + 0, + RomCacheKey::Collection(7), + LibrarySelectionKey::Collection { id: 7 }, + 3, + "collection_id", + ), + ( + 1, + RomCacheKey::SmartCollection(7), + LibrarySelectionKey::SmartCollection { id: 7 }, + 4, + "smart_collection_id", + ), + ( + 2, + RomCacheKey::VirtualCollection("recent".into()), + LibrarySelectionKey::VirtualCollection { + id: "recent".into(), + }, + 5, + "virtual_collection_id", + ), + ] { + screen.list_index = index; + assert_eq!(screen.cache_key(), Some(key)); + assert_eq!(screen.expected_rom_count(), count); + let request = screen.get_roms_request().unwrap(); + assert_eq!(request.platform_id, None); + assert_eq!( + request.collection_id, + (target == "collection_id").then_some(7) + ); + assert_eq!( + request.smart_collection_id, + (target == "smart_collection_id").then_some(7) + ); + assert_eq!( + request.virtual_collection_id.as_deref(), + (target == "virtual_collection_id").then_some("recent") + ); + assert_eq!(request.limit, Some(50)); + let saved = Some(LibrarySelectionState { + subsection: LibrarySubsectionKind::Collection, + key: saved_key, + }); + assert_eq!(screen.selection_state(), saved); + screen.list_index = (index + 1) % 3; + assert!(screen.restore_selection(&saved)); + assert_eq!(screen.list_index, index); + } + screen.list_index = 1; + let candidates = screen.collection_prefetch_candidates(1); + assert_eq!( + candidates + .iter() + .map(|(key, _, count)| (key.clone(), *count)) + .collect::>(), + vec![ + (RomCacheKey::Collection(7), 3), + (RomCacheKey::VirtualCollection("recent".into()), 5) + ] + ); + assert_eq!(candidates[0].1.collection_id, Some(7)); + assert_eq!( + candidates[1].1.virtual_collection_id.as_deref(), + Some("recent") + ); +} + +#[test] +fn virtual_selection_without_id_keeps_request_absence_distinct_from_empty_id() { + use super::LibrarySubsection; + use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind}; + use romm_api::core::cache::RomCacheKey; + + let mut screen = LibraryBrowseScreen::new( + vec![], + vec![serde_json::from_value(json!({ + "id": 0, "name": "Virtual", "rom_count": 2, + "is_virtual": true, "virtual_id": null + })) + .unwrap()], + LEFT_PANEL_PERCENT_DEFAULT, + ); + screen.subsection = LibrarySubsection::ByCollection; + assert_eq!( + screen.cache_key(), + Some(RomCacheKey::VirtualCollection(String::new())) + ); + assert_eq!( + screen.get_roms_request().unwrap().virtual_collection_id, + None + ); + let saved = Some(LibrarySelectionState { + subsection: LibrarySubsectionKind::Collection, + key: LibrarySelectionKey::VirtualCollection { id: String::new() }, + }); + assert_eq!(screen.selection_state(), saved); + assert!(!screen.restore_selection(&saved)); + screen.subsection = LibrarySubsection::ByCollection; + screen.collections[0].virtual_id = Some(String::new()); + assert_eq!( + screen.get_roms_request().unwrap().virtual_collection_id, + Some(String::new()) + ); + assert!(screen.restore_selection(&saved)); + screen.collections[0].rom_count = None; + assert_eq!(screen.expected_rom_count(), 0); + assert!(screen.get_roms_request().is_none()); +}