Files
soulseek-rs-modded/soulseek-rs/tests/tui_e2e.rs
T
Michel de Graaf 26b551d997 feat: browse your own shares as a peer receives them
The window could show any peer's share tree but never this account's, so
"am I actually sharing that?" had no answer short of asking a friend to
browse you. The tree is already there; what was missing was feeding it
from our own index.

B opens it — the same collapsible tree, filter, and navigation the peer
browse offers, built from `Shares::directories()`, which is the listing
GetShareFileList answers with. So what it shows is what the network sees,
not what the config meant to share. r re-scans the disk rather than
re-asking a peer, o steps across to the folders that feed it, and nothing
here queues a download from ourselves.

Attached to a daemon the index lives on the daemon's host, so it comes
back over a new `shares.files` method rather than being scanned locally;
`shares files [--filter]` prints the same listing headless, through the
`emit_listing` that `browse` now shares with it — the two are only
comparable while they are built the same way.

Two allocation fixes came out of the review, both on paths this reaches:
the daemon hands its listing to the DTO instead of deep-copying one it
already owns, and `Shares::directories()` allocates its map key once per
folder rather than once per file, which the peer-facing serve path has
been paying for all along.
2026-09-10 11:44:57 +02:00

233 lines
8.0 KiB
Rust

//! 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::{Soulfind, settle};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use soulseek_rs::Client;
use soulseek_rs_tui::MainTui;
use soulseek_rs_tui::models::SearchStatus;
use soulseek_rs_tui::persist::state::StateStore;
use std::path::Path;
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);
macro_rules! server_or_skip {
() => {
match Soulfind::start() {
Some(server) => server,
None => {
common::no_server("tui e2e");
return;
}
}
};
}
/// 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, and hand back the
/// screen it stopped on. Past `timeout` the test fails showing the last
/// screen, what the user would have been looking at, under `what`.
fn run_until(
&mut self,
timeout: Duration,
what: &str,
mut done: impl FnMut(&MainTui, &str) -> bool,
) -> String {
let deadline = Instant::now() + timeout;
loop {
let screen = self.frame();
if done(&self.tui, &screen) {
return screen;
}
assert!(Instant::now() <= deadline, "{what}:\n{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()
}
}
/// 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")
}
/// Whether the one search has run its window out.
fn finished(tui: &MainTui, _: &str) -> bool {
tui.state().searches[0].status == SearchStatus::Completed
}
/// The window's answer to "am I actually sharing that?", against a real
/// scan of a real folder rather than a stand-in listing.
#[test]
fn shift_b_shows_the_scanned_index_the_way_a_peer_would_get_it() {
let server = server_or_skip!();
let share = tempfile::tempdir().expect("share dir");
std::fs::create_dir_all(share.path().join("album")).expect("subfolder");
std::fs::write(
share.path().join("album").join("tui_probe_own.bin"),
[3u8; 32],
)
.expect("share file");
let state = tempfile::tempdir().expect("state dir");
let downloads = tempfile::tempdir().expect("download dir");
let me = Arc::new(server.client(
"tui_e2e_own_shares",
vec![share.path().display().to_string()],
));
let mut window = Window::open(me, state.path(), downloads.path());
window.press(KeyCode::Char('B'));
window.press(KeyCode::Char('L'));
let screen = window.frame();
assert!(screen.contains("My shares"), "{screen}");
assert!(
screen.contains("album") && screen.contains("tui_probe_own.bin"),
"the scanned tree, folder by folder:\n{screen}"
);
window.press(KeyCode::Esc);
assert!(!window.tui.state().show_browse, "Esc hides the popup");
}
#[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"), [7u8; 64])
.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}"
);
// One key, no typing: the window opens on the list with the query
// highlighted.
window.press(KeyCode::Char('S'));
window.run_until(
Duration::from_secs(15),
"the sharer's file should reach the screen",
shows_the_shared_file,
);
// The search runs its window out and settles, keeping what it found.
let screen = window.run_until(
SEARCH_WINDOW + Duration::from_secs(5),
"the search should finish",
finished,
);
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'));
window.press(KeyCode::Char('S'));
window.run_until(
Duration::from_secs(15),
"the second run should be answered too",
shows_the_shared_file,
);
window.run_until(
SEARCH_WINDOW + Duration::from_secs(5),
"the second run should finish",
finished,
);
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");
}