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:
Michel de Graaf
2026-09-08 13:11:40 +02:00
parent af1f9241e4
commit 76f4854169
10 changed files with 550 additions and 63 deletions
+8
View File
@@ -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.
+8 -6
View File
@@ -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:
+6
View File
@@ -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;
+11 -7
View File
@@ -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();
} }
+143 -23
View File
@@ -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");
}
} }
+4 -1
View File
@@ -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"),
+55 -25
View File
@@ -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.
+1 -1
View File
@@ -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::*;
+313
View File
@@ -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");
}
+1
View File
@@ -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"],