feat(tui): run the highlighted search again with S
The search list survives a restart, but a restored query was only ever a reminder: seeing what the network has now meant typing it out again. In the Searches pane, `S` now re-runs the highlighted query in its place — no duplicate row, no reordering — cancelling a run still collecting first and starting the Results display over for what comes back. The key is listed in the shortcut bar, the `?` overlay, the README and the website. `start_search` is split into launching a search and showing it, so both paths share one implementation. A new `tui_e2e` suite drives the real window over a real session against soulfind: keys in, a screen out, a sharing peer on the other end. It restores a query from a previous run's state directory, presses `S`, and checks the peer's file lands on screen — then again once the query has been on the wire, proving a repeat is answered and the display does not stack. To make that possible `MainTui` is exported with `handle_key_event`, `render`, `poll_session` and a `state()` accessor, and CI runs the suite with `SOULSEEK_E2E_REQUIRED=1`. Closes #85
This commit is contained in:
@@ -196,31 +196,30 @@ impl MainTui {
|
||||
result
|
||||
}
|
||||
|
||||
/// What the window holds, for a test to read back after driving it.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> &AppState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// One frame's worth of catching up with the session: everything the
|
||||
/// event loop does between drawing and reading the keyboard.
|
||||
pub fn poll_session(&mut self) {
|
||||
self.update_search_results();
|
||||
self.update_downloads();
|
||||
self.poll_private_messages();
|
||||
self.poll_browse_result();
|
||||
self.poll_room_events();
|
||||
self.state.uploads = self.client.uploads();
|
||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||
self.save_persisted_state();
|
||||
}
|
||||
|
||||
fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||
while !self.state.should_exit {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
|
||||
// Poll for search results updates
|
||||
self.update_search_results();
|
||||
|
||||
// Poll for download updates
|
||||
self.update_downloads();
|
||||
|
||||
// Poll for incoming private messages
|
||||
self.poll_private_messages();
|
||||
|
||||
// Poll for a browse (shared-file listing) response
|
||||
self.poll_browse_result();
|
||||
|
||||
// Poll for chat-room events
|
||||
self.poll_room_events();
|
||||
|
||||
// Refresh the uploads we are serving to peers
|
||||
self.state.uploads = self.client.uploads();
|
||||
|
||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||
|
||||
self.save_persisted_state();
|
||||
self.poll_session();
|
||||
|
||||
// Drain every queued input event before the next draw: key
|
||||
// autorepeat outpaces the frame time, and handling one event per
|
||||
@@ -279,6 +278,8 @@ mod tests {
|
||||
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
|
||||
/// What `try_get_search_results` hands back to the window.
|
||||
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
|
||||
/// Every query a window put on the wire, in order.
|
||||
searched: std::sync::Mutex<Vec<String>>,
|
||||
/// How many times a window asked for the search list.
|
||||
all_searches_calls: std::sync::atomic::AtomicUsize,
|
||||
/// How many times a window pulled a full result set across. Against a
|
||||
@@ -347,10 +348,14 @@ mod tests {
|
||||
}
|
||||
fn search_with_cancel(
|
||||
&self,
|
||||
_query: &str,
|
||||
query: &str,
|
||||
_timeout: Duration,
|
||||
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
|
||||
self.searched
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.push(query.to_string());
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn get_search_results(
|
||||
@@ -1954,11 +1959,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_searches_pane_lists_its_clear_all_key() {
|
||||
fn the_searches_pane_lists_its_clear_all_and_rerun_keys() {
|
||||
let mut tui = furnished_tui();
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
let screen = screen_sized(&mut tui, 300, 40);
|
||||
assert!(screen.contains("[C → clear all]"), "{screen}");
|
||||
assert!(screen.contains("[S → search again]"), "{screen}");
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("run the search again"), "{screen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2051,4 +2060,115 @@ mod tests {
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(!screen.contains("@@x/Music/Album"), "{screen}");
|
||||
}
|
||||
|
||||
/// The queries the window has sent so far, once at least `count` of them
|
||||
/// are out: a search goes to the session from a worker thread, so a test
|
||||
/// has to give it a moment.
|
||||
fn searched(session: &TalkativeSession, count: usize) -> Vec<String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let seen = session.searched.lock().expect("not poisoned").clone();
|
||||
if seen.len() >= count || std::time::Instant::now() > deadline {
|
||||
return seen;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// A window over last session's search list: the queries came back from
|
||||
/// disk, with nothing under them.
|
||||
fn restored(session: Arc<TalkativeSession>, queries: &[&str]) -> MainTui {
|
||||
let mut tui = attach(session);
|
||||
let queries: Vec<String> =
|
||||
queries.iter().map(ToString::to_string).collect();
|
||||
restore_searches(&mut tui.state, &queries);
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
tui
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_s_runs_the_highlighted_search_again_in_its_place() {
|
||||
// The list survives a restart, but a query on it was only ever a
|
||||
// reminder: seeing what the network has now meant typing it again.
|
||||
let session = Arc::new(TalkativeSession::default());
|
||||
let mut tui =
|
||||
restored(session.clone(), &["boards of canada", "aphex twin"]);
|
||||
tui.state.searches_table_state.select(Some(1));
|
||||
|
||||
press(&mut tui, KeyCode::Char('S'));
|
||||
|
||||
assert_eq!(
|
||||
searched(&session, 1),
|
||||
vec!["aphex twin".to_string()],
|
||||
"the highlighted query went out again, untyped"
|
||||
);
|
||||
let queries: Vec<&str> = tui
|
||||
.state
|
||||
.searches
|
||||
.iter()
|
||||
.map(|s| s.query.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
queries,
|
||||
["boards of canada", "aphex twin"],
|
||||
"it keeps its row: no duplicate, nothing moved"
|
||||
);
|
||||
assert_eq!(tui.state.searches[1].status, SearchStatus::Active);
|
||||
assert_eq!(tui.state.selected_search_index, Some(1));
|
||||
assert_eq!(
|
||||
tui.state.focused_pane,
|
||||
FocusedPane::Results,
|
||||
"and the window turns to where the results will land"
|
||||
);
|
||||
assert!(tui.state.results_items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_s_stops_a_run_still_collecting_before_starting_over() {
|
||||
let mut tui = furnished_tui();
|
||||
tui.state.searches[0].status = SearchStatus::Active;
|
||||
tui.state.searches[0].start_time = std::time::Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.expect("two seconds ago");
|
||||
let previous = tui.state.searches[0].cancel_flag.clone();
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
tui.state.searches_table_state.select(Some(0));
|
||||
|
||||
press(&mut tui, KeyCode::Char('S'));
|
||||
|
||||
assert!(
|
||||
previous.load(std::sync::atomic::Ordering::Relaxed),
|
||||
"the worker that was collecting is told to stop"
|
||||
);
|
||||
let fresh = &tui.state.searches[0];
|
||||
assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert!(fresh.results.is_empty(), "stale rows do not linger");
|
||||
assert_eq!(tui.state.searches.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_s_is_only_a_key_over_the_searches_list() {
|
||||
let session = Arc::new(TalkativeSession::default());
|
||||
let mut tui = restored(session.clone(), &["aphex twin"]);
|
||||
|
||||
// Over Results the same key means nothing, so a stray shift does not
|
||||
// throw a finished search away.
|
||||
tui.state.focused_pane = FocusedPane::Results;
|
||||
press(&mut tui, KeyCode::Char('S'));
|
||||
assert_eq!(tui.state.searches[0].status, SearchStatus::Completed);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(searched(&session, 0).is_empty(), "nothing went out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_shift_s_is_one_search_not_one_per_repeat() {
|
||||
let session = Arc::new(TalkativeSession::default());
|
||||
let mut tui = restored(session.clone(), &["aphex twin"]);
|
||||
press(&mut tui, KeyCode::Char('S'));
|
||||
press(&mut tui, KeyCode::Char('1'));
|
||||
press(&mut tui, KeyCode::Char('S'));
|
||||
assert_eq!(searched(&session, 1).len(), 1);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user