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:
@@ -110,6 +110,14 @@ jobs:
|
|||||||
SOULSEEK_E2E_REQUIRED: "1"
|
SOULSEEK_E2E_REQUIRED: "1"
|
||||||
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
|
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
|
||||||
|
|
||||||
|
# The window over a real session: keys in, a screen out, a peer on the
|
||||||
|
# other end. The unit tests drive it against a stand-in session and the
|
||||||
|
# CLI suite never opens it, so this is the only place the two meet.
|
||||||
|
- name: Run TUI e2e tests (required against soulfind)
|
||||||
|
env:
|
||||||
|
SOULSEEK_E2E_REQUIRED: "1"
|
||||||
|
run: cargo test -p soulseek-rs --test tui_e2e -- --nocapture
|
||||||
|
|
||||||
# The control protocol as a third party meets it: the TCP transport, the
|
# The control protocol as a third party meets it: the TCP transport, the
|
||||||
# token, and the gate. None of it is reachable through the command
|
# token, and the gate. None of it is reachable through the command
|
||||||
# surface, so the CLI suite cannot cover it in either mode.
|
# surface, so the CLI suite cannot cover it in either mode.
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ zooms the focused pane to the whole window, `w` hides it so the others widen
|
|||||||
takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or
|
takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or
|
||||||
`←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to
|
`←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to
|
||||||
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
|
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
|
||||||
shows every key for where you are.
|
shows every key for where you are. `s` starts a search, and `S` runs the
|
||||||
|
highlighted one again.
|
||||||
|
|
||||||
### Daemon mode
|
### Daemon mode
|
||||||
|
|
||||||
@@ -620,11 +621,12 @@ cargo fmt
|
|||||||
|
|
||||||
### End-to-end tests
|
### End-to-end tests
|
||||||
|
|
||||||
Two suites run against [soulfind](https://github.com/soulfind-dev/soulfind), a
|
Three suites run against [soulfind](https://github.com/soulfind-dev/soulfind),
|
||||||
local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
|
a local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
|
||||||
library, and `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
|
library, `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
|
||||||
would. Both are **server-optional**, running when a server is available and
|
would, and `soulseek-rs/tests/tui_e2e.rs` drives the window with key presses
|
||||||
skipping otherwise, so `cargo test` stays green everywhere.
|
and reads the screen back. All are **server-optional**, running when a server
|
||||||
|
is available and skipping otherwise, so `cargo test` stays green everywhere.
|
||||||
|
|
||||||
They locate a server in this order:
|
They locate a server in this order:
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,9 @@ pub(crate) mod port_mapping;
|
|||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod run;
|
pub mod run;
|
||||||
pub(crate) mod ui;
|
pub(crate) mod ui;
|
||||||
|
|
||||||
|
/// The interactive window, reachable so an integration test can drive it
|
||||||
|
/// with key events, advance it a frame at a time and read the screen back.
|
||||||
|
/// Hidden from the documented API: it is a harness door, not a contract.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use ui::MainTui;
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ use ratatui::widgets::TableState;
|
|||||||
const NAME_SCROLL_STEP: isize = 8;
|
const NAME_SCROLL_STEP: isize = 8;
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
|
/// One key press, routed to whatever owns the keyboard right now.
|
||||||
|
pub fn handle_key_event(&mut self, key: KeyEvent) {
|
||||||
// Command bar takes priority
|
// Command bar takes priority
|
||||||
if self.state.command_bar_active {
|
if self.state.command_bar_active {
|
||||||
return self.handle_command_bar_input(key);
|
return self.handle_command_bar_input(key);
|
||||||
@@ -400,11 +401,10 @@ impl MainTui {
|
|||||||
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let selected = self.state.searches_table_state.selected();
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some(selected) =
|
if let Some(selected) = selected {
|
||||||
self.state.searches_table_state.selected()
|
|
||||||
{
|
|
||||||
self.state.selected_search_index = Some(selected);
|
self.state.selected_search_index = Some(selected);
|
||||||
if let Some(search) = self.state.searches.get(selected) {
|
if let Some(search) = self.state.searches.get(selected) {
|
||||||
self.state.results_items = search.results.clone();
|
self.state.results_items = search.results.clone();
|
||||||
@@ -420,12 +420,16 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') => {
|
KeyCode::Char('d') => {
|
||||||
if let Some(selected) =
|
if let Some(selected) = selected {
|
||||||
self.state.searches_table_state.selected()
|
|
||||||
{
|
|
||||||
self.remove_search_at_index(selected);
|
self.remove_search_at_index(selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// `s` asks for a query; `S` reuses the highlighted one.
|
||||||
|
KeyCode::Char('S') => {
|
||||||
|
if let Some(selected) = selected {
|
||||||
|
self.rerun_search_at_index(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Char('C') => {
|
KeyCode::Char('C') => {
|
||||||
self.clear_all_searches();
|
self.clear_all_searches();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,31 +196,30 @@ impl MainTui {
|
|||||||
result
|
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<()> {
|
fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||||
while !self.state.should_exit {
|
while !self.state.should_exit {
|
||||||
terminal.draw(|frame| self.render(frame))?;
|
terminal.draw(|frame| self.render(frame))?;
|
||||||
|
|
||||||
// Poll for search results updates
|
self.poll_session();
|
||||||
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();
|
|
||||||
|
|
||||||
// Drain every queued input event before the next draw: key
|
// Drain every queued input event before the next draw: key
|
||||||
// autorepeat outpaces the frame time, and handling one event per
|
// autorepeat outpaces the frame time, and handling one event per
|
||||||
@@ -279,6 +278,8 @@ mod tests {
|
|||||||
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
|
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
|
||||||
/// What `try_get_search_results` hands back to the window.
|
/// What `try_get_search_results` hands back to the window.
|
||||||
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
|
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.
|
/// How many times a window asked for the search list.
|
||||||
all_searches_calls: std::sync::atomic::AtomicUsize,
|
all_searches_calls: std::sync::atomic::AtomicUsize,
|
||||||
/// How many times a window pulled a full result set across. Against a
|
/// How many times a window pulled a full result set across. Against a
|
||||||
@@ -347,10 +348,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
fn search_with_cancel(
|
fn search_with_cancel(
|
||||||
&self,
|
&self,
|
||||||
_query: &str,
|
query: &str,
|
||||||
_timeout: Duration,
|
_timeout: Duration,
|
||||||
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
|
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||||
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
|
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
|
||||||
|
self.searched
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.push(query.to_string());
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
fn get_search_results(
|
fn get_search_results(
|
||||||
@@ -1954,11 +1959,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut tui = furnished_tui();
|
||||||
tui.state.focused_pane = FocusedPane::Searches;
|
tui.state.focused_pane = FocusedPane::Searches;
|
||||||
let screen = screen_sized(&mut tui, 300, 40);
|
let screen = screen_sized(&mut tui, 300, 40);
|
||||||
assert!(screen.contains("[C → clear all]"), "{screen}");
|
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]
|
#[test]
|
||||||
@@ -2051,4 +2060,115 @@ mod tests {
|
|||||||
let screen = screen_of(&mut tui);
|
let screen = screen_of(&mut tui);
|
||||||
assert!(!screen.contains("@@x/Music/Album"), "{screen}");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
"Searches",
|
"Searches",
|
||||||
&[
|
&[
|
||||||
("Enter", "show its results"),
|
("Enter", "show its results"),
|
||||||
|
("S", "run the search again"),
|
||||||
("d", "remove the search"),
|
("d", "remove the search"),
|
||||||
("C", "clear every search"),
|
("C", "clear every search"),
|
||||||
("c", "chat rooms"),
|
("c", "chat rooms"),
|
||||||
@@ -126,7 +127,8 @@ fn filter_keys(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
pub(super) fn render(&mut self, frame: &mut Frame) {
|
/// Draw the whole window.
|
||||||
|
pub fn render(&mut self, frame: &mut Frame) {
|
||||||
let shortcuts = self.shortcut_rows(frame.area().width);
|
let shortcuts = self.shortcut_rows(frame.area().width);
|
||||||
let mut constraints = vec![
|
let mut constraints = vec![
|
||||||
Constraint::Length(3), // Status bar
|
Constraint::Length(3), // Status bar
|
||||||
@@ -631,6 +633,7 @@ impl MainTui {
|
|||||||
let mut keys = match self.state.focused_pane {
|
let mut keys = match self.state.focused_pane {
|
||||||
FocusedPane::Searches => vec![
|
FocusedPane::Searches => vec![
|
||||||
("s", "search"),
|
("s", "search"),
|
||||||
|
("S", "search again"),
|
||||||
("Enter", "results"),
|
("Enter", "results"),
|
||||||
("d", "remove"),
|
("d", "remove"),
|
||||||
("C", "clear all"),
|
("C", "clear all"),
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ use crate::models::{
|
|||||||
};
|
};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use std::{
|
use std::{
|
||||||
sync::{Arc, atomic::AtomicBool},
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
thread,
|
thread,
|
||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// How long after a search went out a repeat of `S` counts as the same
|
||||||
|
/// press: key autorepeat is faster than anyone means to search.
|
||||||
|
const RERUN_HOLD: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
pub(super) fn remove_search_at_index(&mut self, index: usize) {
|
pub(super) fn remove_search_at_index(&mut self, index: usize) {
|
||||||
if index >= self.state.searches.len() {
|
if index >= self.state.searches.len() {
|
||||||
@@ -18,9 +25,7 @@ impl MainTui {
|
|||||||
|
|
||||||
// Cancel the search if it's active
|
// Cancel the search if it's active
|
||||||
if let Some(search) = self.state.searches.get(index) {
|
if let Some(search) = self.state.searches.get(index) {
|
||||||
search
|
search.cancel_flag.store(true, Ordering::Relaxed);
|
||||||
.cancel_flag
|
|
||||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're removing the currently active search
|
// Check if we're removing the currently active search
|
||||||
@@ -66,9 +71,7 @@ impl MainTui {
|
|||||||
pub(super) fn clear_all_searches(&mut self) {
|
pub(super) fn clear_all_searches(&mut self) {
|
||||||
// Cancel all active searches
|
// Cancel all active searches
|
||||||
for search in &self.state.searches {
|
for search in &self.state.searches {
|
||||||
search
|
search.cancel_flag.store(true, Ordering::Relaxed);
|
||||||
.cancel_flag
|
|
||||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// And drop them from the session, or the next sync brings every one
|
// And drop them from the session, or the next sync brings every one
|
||||||
@@ -205,6 +208,39 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn start_search(&mut self, query: String) {
|
pub(super) fn start_search(&mut self, query: String) {
|
||||||
|
let entry = self.launch_search(query);
|
||||||
|
self.state.searches.push(entry);
|
||||||
|
self.show_search(self.state.searches.len() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the search at `index` again, in its place.
|
||||||
|
///
|
||||||
|
/// This is what a restored list is for: last session's queries come back
|
||||||
|
/// with nothing under them, and typing one out again to see what the
|
||||||
|
/// network has now is the work the list was meant to save. The entry
|
||||||
|
/// keeps its row rather than moving to the end — the user is looking at
|
||||||
|
/// it — and a run still collecting is stopped first, or two threads would
|
||||||
|
/// be pouring into one result set.
|
||||||
|
pub(super) fn rerun_search_at_index(&mut self, index: usize) {
|
||||||
|
let Some(previous) = self.state.searches.get(index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// A held key repeats, and every repeat would put another search on
|
||||||
|
// the wire: a run that only just went out is left to run.
|
||||||
|
if previous.status == SearchStatus::Active
|
||||||
|
&& previous.start_time.elapsed() < RERUN_HOLD
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previous.cancel_flag.store(true, Ordering::Relaxed);
|
||||||
|
self.state.searches[index] = self.launch_search(previous.query.clone());
|
||||||
|
self.show_search(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `query` on the wire and hand back the entry that tracks it. The
|
||||||
|
/// session keys results by query, so a repeat starts its set afresh, and
|
||||||
|
/// the entry's empty results match that.
|
||||||
|
fn launch_search(&self, query: String) -> SearchEntry {
|
||||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||||
let search_entry = SearchEntry {
|
let search_entry = SearchEntry {
|
||||||
query: query.clone(),
|
query: query.clone(),
|
||||||
@@ -216,24 +252,6 @@ impl MainTui {
|
|||||||
cancel_flag: cancel_flag.clone(),
|
cancel_flag: cancel_flag.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.state.searches.push(search_entry);
|
|
||||||
let search_index = self.state.searches.len() - 1;
|
|
||||||
self.state.searches_table_state.select(Some(search_index));
|
|
||||||
|
|
||||||
// Make this search the active one
|
|
||||||
self.state.selected_search_index = Some(search_index);
|
|
||||||
|
|
||||||
// Initialize results display (empty at first)
|
|
||||||
self.state.results_items.clear();
|
|
||||||
self.state.results_filtered_items.clear();
|
|
||||||
self.state.results_filtered_indices.clear();
|
|
||||||
self.state.results_selected_indices.clear();
|
|
||||||
self.state.results_table_state.select(Some(0));
|
|
||||||
self.state.results_name_offset = 0;
|
|
||||||
|
|
||||||
// Switch focus to Results pane, bringing it back if it was hidden
|
|
||||||
self.state.focus_pane(FocusedPane::Results);
|
|
||||||
|
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let timeout = self.search_timeout;
|
let timeout = self.search_timeout;
|
||||||
|
|
||||||
@@ -251,6 +269,18 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
search_entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the search at `index` the one the Results pane shows, starting
|
||||||
|
/// from an empty display, filter and all, for what is about to arrive.
|
||||||
|
fn show_search(&mut self, index: usize) {
|
||||||
|
self.clear_results_pane();
|
||||||
|
self.state.searches_table_state.select(Some(index));
|
||||||
|
self.state.selected_search_index = Some(index);
|
||||||
|
self.state.results_table_state.select(Some(0));
|
||||||
|
self.state.focus_pane(FocusedPane::Results);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the searches the session knows about, not just this window's.
|
/// Show the searches the session knows about, not just this window's.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ mod styles;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use downloads::render_download_stats;
|
pub use downloads::render_download_stats;
|
||||||
pub use main_tui::launch_main_tui;
|
pub use main_tui::{MainTui, launch_main_tui};
|
||||||
pub use paging::*;
|
pub use paging::*;
|
||||||
pub use styles::*;
|
pub use styles::*;
|
||||||
pub use utils::*;
|
pub use utils::*;
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! End-to-end tests of the window itself against a real Soulseek server:
|
||||||
|
//! key presses in, a screen out, with soulfind and a real sharing peer on the
|
||||||
|
//! other end of the wire.
|
||||||
|
//!
|
||||||
|
//! The CLI suite proves the command surface; the unit tests under `ui` prove
|
||||||
|
//! the window's keys against a stand-in session. What neither covers is the
|
||||||
|
//! two together — a key that is supposed to put a search on the network and
|
||||||
|
//! bring its answers back onto the screen. The window is driven in-process
|
||||||
|
//! through the same handlers the terminal feeds, rendered onto a test
|
||||||
|
//! backend, and the session is a real client logged in to a real server.
|
||||||
|
//!
|
||||||
|
//! Like the rest of the e2e suite this needs a soulfind server and skips
|
||||||
|
//! without one; `SOULSEEK_E2E_REQUIRED=1` turns that skip into a failure.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::{free_port, soulfind_binary};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use soulseek_rs::{Client, ClientSettings, PeerAddress};
|
||||||
|
use soulseek_rs_tui::MainTui;
|
||||||
|
use soulseek_rs_tui::models::{FocusedPane, SearchStatus};
|
||||||
|
use soulseek_rs_tui::persist::state::StateStore;
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// How long a search collects for. Short, because every run here has to wait
|
||||||
|
/// it out; long enough for a peer on loopback to answer many times over.
|
||||||
|
const SEARCH_WINDOW: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// A soulfind spawned for one test, on a port nobody else holds.
|
||||||
|
struct Soulfind {
|
||||||
|
child: Child,
|
||||||
|
db: std::path::PathBuf,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Soulfind {
|
||||||
|
fn start() -> Option<Self> {
|
||||||
|
let bin = soulfind_binary()?;
|
||||||
|
let port = free_port()?;
|
||||||
|
let db = std::env::temp_dir().join(format!("soulfind-tui-{port}.db"));
|
||||||
|
let _ = std::fs::remove_file(&db);
|
||||||
|
|
||||||
|
let mut child = Command::new(bin)
|
||||||
|
.args(["-d", db.to_str()?, "-p", &port.to_string()])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||||
|
return Some(Self { child, db, port });
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client logged in to this server as `user`, sharing `shares`.
|
||||||
|
///
|
||||||
|
/// The listener stays on: a peer delivers search results over a
|
||||||
|
/// connection it opens back to the searcher, so a searcher without one
|
||||||
|
/// hears nothing.
|
||||||
|
fn client(&self, user: &str, shares: Vec<String>) -> Client {
|
||||||
|
let mut client = Client::with_settings(ClientSettings {
|
||||||
|
username: user.to_string(),
|
||||||
|
password: "pw".to_string(),
|
||||||
|
server_address: PeerAddress::new(
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
self.port,
|
||||||
|
),
|
||||||
|
enable_listen: true,
|
||||||
|
listen_port: free_port().expect("peer port"),
|
||||||
|
shared_directories: shares,
|
||||||
|
version: soulseek_rs::ClientVersion::default(),
|
||||||
|
});
|
||||||
|
client.connect().expect("peer connect");
|
||||||
|
assert!(client.login().expect("peer login"), "peer should log in");
|
||||||
|
client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Soulfind {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
let _ = std::fs::remove_file(&self.db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! server_or_skip {
|
||||||
|
() => {
|
||||||
|
match Soulfind::start() {
|
||||||
|
Some(server) => server,
|
||||||
|
None => {
|
||||||
|
let required = std::env::var("SOULSEEK_E2E_REQUIRED")
|
||||||
|
.is_ok_and(|v| v != "0" && !v.is_empty());
|
||||||
|
assert!(
|
||||||
|
!required,
|
||||||
|
"SOULSEEK_E2E_REQUIRED is set but no soulfind server could \
|
||||||
|
be started (set SOULFIND_BIN)"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"tui e2e skipped: no soulfind server (set SOULFIND_BIN to \
|
||||||
|
run)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait out the SetWaitPort registrations so peer lookups resolve.
|
||||||
|
fn settle() {
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window under test: the real `MainTui` over a real session, drawn onto
|
||||||
|
/// a buffer instead of a terminal.
|
||||||
|
struct Window {
|
||||||
|
tui: MainTui,
|
||||||
|
terminal: Terminal<TestBackend>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Window {
|
||||||
|
/// Open the window as the terminal would on start-up, over `state` — the
|
||||||
|
/// directory a previous run left its search list in.
|
||||||
|
fn open(client: Arc<Client>, state: &Path, downloads: &Path) -> Self {
|
||||||
|
let tui = MainTui::new(
|
||||||
|
client,
|
||||||
|
downloads.display().to_string(),
|
||||||
|
SEARCH_WINDOW,
|
||||||
|
Some(StateStore::new(state.to_path_buf())),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let terminal = Terminal::new(TestBackend::new(160, 40))
|
||||||
|
.expect("a test backend always opens");
|
||||||
|
Self { tui, terminal }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(&mut self, code: KeyCode) {
|
||||||
|
self.tui
|
||||||
|
.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One turn of the event loop: catch up with the session, then draw.
|
||||||
|
/// Returns the screen as the user would see it.
|
||||||
|
fn frame(&mut self) -> String {
|
||||||
|
self.tui.poll_session();
|
||||||
|
self.terminal
|
||||||
|
.draw(|frame| self.tui.render(frame))
|
||||||
|
.expect("draw");
|
||||||
|
self.terminal.backend().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the window running until `done` says so, or `timeout` runs out.
|
||||||
|
/// Returns the last screen either way, so a failing test shows what the
|
||||||
|
/// user would have been looking at.
|
||||||
|
fn run_until(
|
||||||
|
&mut self,
|
||||||
|
timeout: Duration,
|
||||||
|
mut done: impl FnMut(&MainTui, &str) -> bool,
|
||||||
|
) -> (bool, String) {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let screen = self.frame();
|
||||||
|
if done(&self.tui, &screen) {
|
||||||
|
return (true, screen);
|
||||||
|
}
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return (false, screen);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File names in the Results pane right now.
|
||||||
|
fn result_names(&self) -> Vec<String> {
|
||||||
|
self.tui
|
||||||
|
.state()
|
||||||
|
.results_items
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.filename.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_status(&self, index: usize) -> SearchStatus {
|
||||||
|
self.tui.state().searches[index].status.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the file the sharer offers has reached the Results pane, on
|
||||||
|
/// screen as well as in the state behind it.
|
||||||
|
fn shows_the_shared_file(tui: &MainTui, screen: &str) -> bool {
|
||||||
|
tui.state()
|
||||||
|
.results_items
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.filename.contains("tui_probe_rerun"))
|
||||||
|
&& screen.contains("tui_probe_rerun")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
// The other end of the wire: a peer sharing one file with a name only
|
||||||
|
// this test searches for.
|
||||||
|
let share = tempfile::tempdir().expect("share dir");
|
||||||
|
std::fs::write(
|
||||||
|
share.path().join("tui_probe_rerun_vexq.bin"),
|
||||||
|
(0..4096u32).map(|i| (i % 251) as u8).collect::<Vec<u8>>(),
|
||||||
|
)
|
||||||
|
.expect("share file");
|
||||||
|
let _sharer = server
|
||||||
|
.client("tui_e2e_sharer", vec![share.path().display().to_string()]);
|
||||||
|
settle();
|
||||||
|
|
||||||
|
// Last time this user ran the window they searched for "vexq" and quit.
|
||||||
|
// The list came back with them; the results did not.
|
||||||
|
let state = tempfile::tempdir().expect("state dir");
|
||||||
|
let downloads = tempfile::tempdir().expect("download dir");
|
||||||
|
StateStore::new(state.path().to_path_buf())
|
||||||
|
.save_search_queries(&["vexq".to_string()])
|
||||||
|
.expect("a previous run's search list");
|
||||||
|
|
||||||
|
let seeker = Arc::new(server.client("tui_e2e_seeker", Vec::new()));
|
||||||
|
let mut window = Window::open(seeker, state.path(), downloads.path());
|
||||||
|
|
||||||
|
let screen = window.frame();
|
||||||
|
assert!(
|
||||||
|
screen.contains("vexq"),
|
||||||
|
"the old query is listed:\n{screen}"
|
||||||
|
);
|
||||||
|
assert_eq!(window.tui.state().searches.len(), 1);
|
||||||
|
assert_eq!(window.search_status(0), SearchStatus::Completed);
|
||||||
|
assert!(
|
||||||
|
window.result_names().is_empty(),
|
||||||
|
"and it has nothing under it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window.tui.state().focused_pane,
|
||||||
|
FocusedPane::Searches,
|
||||||
|
"the window opens on the list, so the query is already highlighted"
|
||||||
|
);
|
||||||
|
|
||||||
|
// One key, no typing.
|
||||||
|
window.press(KeyCode::Char('S'));
|
||||||
|
|
||||||
|
assert_eq!(window.tui.state().searches.len(), 1, "no second row");
|
||||||
|
assert_eq!(window.search_status(0), SearchStatus::Active);
|
||||||
|
assert_eq!(window.tui.state().focused_pane, FocusedPane::Results);
|
||||||
|
|
||||||
|
let (found, screen) =
|
||||||
|
window.run_until(Duration::from_secs(15), shows_the_shared_file);
|
||||||
|
assert!(
|
||||||
|
found,
|
||||||
|
"the sharer's file should reach the screen:\n{screen}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
window
|
||||||
|
.result_names()
|
||||||
|
.iter()
|
||||||
|
.all(|name| name.contains("tui_probe_rerun")),
|
||||||
|
"only what the network answered: {:?}",
|
||||||
|
window.result_names()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The search runs its window out and settles, keeping what it found.
|
||||||
|
let (done, screen) = window
|
||||||
|
.run_until(SEARCH_WINDOW + Duration::from_secs(5), |tui, _| {
|
||||||
|
tui.state().searches[0].status == SearchStatus::Completed
|
||||||
|
});
|
||||||
|
assert!(done, "the search should finish:\n{screen}");
|
||||||
|
assert!(shows_the_shared_file(&window.tui, &screen));
|
||||||
|
let first_run = window.result_names();
|
||||||
|
|
||||||
|
// Again, now that the query has been on the wire once in this session:
|
||||||
|
// the same peer has to answer the same searcher a second time, and the
|
||||||
|
// window has to start the display over rather than stack the answers.
|
||||||
|
window.press(KeyCode::Char('1'));
|
||||||
|
assert_eq!(window.tui.state().focused_pane, FocusedPane::Searches);
|
||||||
|
window.press(KeyCode::Char('S'));
|
||||||
|
assert_eq!(window.search_status(0), SearchStatus::Active);
|
||||||
|
assert!(
|
||||||
|
window.result_names().is_empty(),
|
||||||
|
"the display starts over: {:?}",
|
||||||
|
window.result_names()
|
||||||
|
);
|
||||||
|
|
||||||
|
let (found, screen) =
|
||||||
|
window.run_until(Duration::from_secs(15), shows_the_shared_file);
|
||||||
|
assert!(found, "the second run should be answered too:\n{screen}");
|
||||||
|
let (done, _) = window
|
||||||
|
.run_until(SEARCH_WINDOW + Duration::from_secs(5), |tui, _| {
|
||||||
|
tui.state().searches[0].status == SearchStatus::Completed
|
||||||
|
});
|
||||||
|
assert!(done);
|
||||||
|
assert_eq!(
|
||||||
|
window.result_names(),
|
||||||
|
first_run,
|
||||||
|
"the same answer, once, not the old set with the new one on top"
|
||||||
|
);
|
||||||
|
assert_eq!(window.tui.state().searches.len(), 1, "still one row");
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { LINKS } from '@/lib/links'
|
|||||||
|
|
||||||
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
||||||
['s', 'search the network'],
|
['s', 'search the network'],
|
||||||
|
['S', 'run the highlighted search again'],
|
||||||
['Space', 'select a result'],
|
['Space', 'select a result'],
|
||||||
['Enter', 'download / send'],
|
['Enter', 'download / send'],
|
||||||
['b', "browse the owner's files"],
|
['b', "browse the owner's files"],
|
||||||
|
|||||||
Reference in New Issue
Block a user