fix(tui): keep the login screen responsive while connecting
The login screen ignored every key while a connect+login attempt ran, so against an unreachable server it sat on a static "Connecting…" for 20s or more, with a hint promising that Esc would quit. While connecting it now shows a spinner: Enter or r restarts the attempt, Esc stops it and returns to the form, q or Ctrl-C quits. Ctrl-C quits from every state of the screen instead of typing a "c" into a field. A failure that got no verdict (unreachable, timed out) keeps the password, so Enter or r retries it; only a rejection, Ok(false) or AuthenticationFailed, clears it. Because a retry can now reuse a password that came from the CLI, env or keychain, entered_via_form compares the credentials with the ones the flow started with instead of trusting any submit, so a retried stored password is never written to the keychain. A restart of an attempt younger than a second is ignored: each attempt rescans the shares and opens a listener, and a held Enter would otherwise start one per key repeat. The attempt result is read before drawing, so a key is never handled against a phase the screen did not show. A stopped or restarted attempt is abandoned, not cancelled: the server actor blocks its own mailbox in connect_timeout, so a stop could not reach it sooner. Its thread runs out its timeouts and drops the client, which disconnects. Until then it holds the listener port, so a restarted session can fall back to an ephemeral port, and should a slow server accept the abandoned login after its replacement got in, the late login displaces the new session. Fixing either needs a cancellable connect, or a bind after the login verdict, in the library.
This commit is contained in:
+228
-80
@@ -6,21 +6,25 @@
|
|||||||
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
||||||
|
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask,
|
GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char,
|
||||||
pane_block, primary_style, title_style, warning_style,
|
info_style, mask, pane_block, primary_style, title_style, warning_style,
|
||||||
};
|
};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll,
|
||||||
|
},
|
||||||
layout::{Constraint, Flex, Layout, Rect},
|
layout::{Constraint, Flex, Layout, Rect},
|
||||||
style::Modifier,
|
style::Modifier,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Clear, Paragraph},
|
widgets::{Clear, Paragraph},
|
||||||
};
|
};
|
||||||
use soulseek_rs::{Client, ClientSettings};
|
use soulseek_rs::{Client, ClientSettings, SoulseekRs};
|
||||||
use std::sync::mpsc::{Receiver, channel};
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const RESTART_FLOOR: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LoginField {
|
pub enum LoginField {
|
||||||
@@ -36,15 +40,17 @@ pub enum LoginPhase {
|
|||||||
Connecting,
|
Connecting,
|
||||||
/// The last attempt failed; the message is shown until the next key.
|
/// The last attempt failed; the message is shown until the next key.
|
||||||
Failed(String),
|
Failed(String),
|
||||||
|
Rejected(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the caller should do after feeding a key to the form.
|
/// What the caller should do after feeding a key to the form.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LoginAction {
|
pub enum LoginAction {
|
||||||
None,
|
None,
|
||||||
/// Both fields are filled and the user pressed Enter.
|
/// Both fields are filled and the user pressed Enter or r.
|
||||||
Submit,
|
Submit,
|
||||||
/// User pressed Esc — abort the whole program.
|
Stop,
|
||||||
|
/// User pressed Esc, q or Ctrl-C — abort the whole program.
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +78,33 @@ impl LoginForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
|
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
|
||||||
|
if key.code == KeyCode::Char('c')
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
{
|
||||||
|
return LoginAction::Cancel;
|
||||||
|
}
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
LoginPhase::Connecting => return LoginAction::None,
|
LoginPhase::Connecting => {
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit,
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.phase = LoginPhase::Editing;
|
||||||
|
LoginAction::Stop
|
||||||
|
}
|
||||||
|
KeyCode::Char('q') => LoginAction::Cancel,
|
||||||
|
_ => LoginAction::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
LoginPhase::Failed(_) => {
|
LoginPhase::Failed(_) => {
|
||||||
|
self.phase = LoginPhase::Editing;
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('r') => self.submit(),
|
||||||
|
KeyCode::Esc => LoginAction::Cancel,
|
||||||
|
_ => LoginAction::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
LoginPhase::Rejected(_) => {
|
||||||
// Any key acknowledges the error; the password was wrong (or
|
// Any key acknowledges the error; the password was wrong (or
|
||||||
// the name is taken), so make the user retype it.
|
// the name is taken), so make the user retype it.
|
||||||
self.phase = LoginPhase::Editing;
|
self.phase = LoginPhase::Editing;
|
||||||
@@ -97,20 +127,7 @@ impl LoginForm {
|
|||||||
};
|
};
|
||||||
LoginAction::None
|
LoginAction::None
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => self.submit(),
|
||||||
if !self.username.is_empty() && !self.password.is_empty() {
|
|
||||||
self.phase = LoginPhase::Connecting;
|
|
||||||
LoginAction::Submit
|
|
||||||
} else {
|
|
||||||
// Move to the first empty field instead of submitting.
|
|
||||||
self.focused = if self.username.is_empty() {
|
|
||||||
LoginField::Username
|
|
||||||
} else {
|
|
||||||
LoginField::Password
|
|
||||||
};
|
|
||||||
LoginAction::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
self.focused_field_mut().pop();
|
self.focused_field_mut().pop();
|
||||||
LoginAction::None
|
LoginAction::None
|
||||||
@@ -123,6 +140,20 @@ impl LoginForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn submit(&mut self) -> LoginAction {
|
||||||
|
if !self.username.is_empty() && !self.password.is_empty() {
|
||||||
|
self.phase = LoginPhase::Connecting;
|
||||||
|
return LoginAction::Submit;
|
||||||
|
}
|
||||||
|
// Move to the first empty field instead of submitting.
|
||||||
|
self.focused = if self.username.is_empty() {
|
||||||
|
LoginField::Username
|
||||||
|
} else {
|
||||||
|
LoginField::Password
|
||||||
|
};
|
||||||
|
LoginAction::None
|
||||||
|
}
|
||||||
|
|
||||||
const fn focused_field_mut(&mut self) -> &mut String {
|
const fn focused_field_mut(&mut self) -> &mut String {
|
||||||
match self.focused {
|
match self.focused {
|
||||||
LoginField::Username => &mut self.username,
|
LoginField::Username => &mut self.username,
|
||||||
@@ -142,7 +173,7 @@ pub struct LoginOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the login screen until a login succeeds (`Some`) or the user
|
/// Drive the login screen until a login succeeds (`Some`) or the user
|
||||||
/// cancels with Esc (`None`). When both credentials are already known an
|
/// quits (`None`). When both credentials are already known an
|
||||||
/// attempt starts immediately and the form is only shown on failure.
|
/// attempt starts immediately and the form is only shown on failure.
|
||||||
pub fn run_login_flow(
|
pub fn run_login_flow(
|
||||||
terminal: &mut DefaultTerminal,
|
terminal: &mut DefaultTerminal,
|
||||||
@@ -150,27 +181,31 @@ pub fn run_login_flow(
|
|||||||
initial_username: Option<String>,
|
initial_username: Option<String>,
|
||||||
initial_password: Option<String>,
|
initial_password: Option<String>,
|
||||||
) -> Result<Option<LoginOutcome>> {
|
) -> Result<Option<LoginOutcome>> {
|
||||||
let mut form = LoginForm::new(initial_username);
|
let start = |form: &LoginForm| {
|
||||||
let mut entered_via_form = false;
|
spawn_attempt(make_settings(
|
||||||
let mut attempt: Option<Receiver<Result<Client, String>>> =
|
form.username.clone(),
|
||||||
match initial_password {
|
form.password.clone(),
|
||||||
Some(password) if !form.username.is_empty() => {
|
))
|
||||||
form.password = password;
|
};
|
||||||
form.phase = LoginPhase::Connecting;
|
let mut form = LoginForm::new(initial_username.clone());
|
||||||
Some(spawn_attempt(make_settings(
|
let mut attempt = match &initial_password {
|
||||||
form.username.clone(),
|
Some(password) if !form.username.is_empty() => {
|
||||||
form.password.clone(),
|
form.password.clone_from(password);
|
||||||
)))
|
form.phase = LoginPhase::Connecting;
|
||||||
}
|
Some(start(&form))
|
||||||
_ => None,
|
}
|
||||||
};
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut started = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| render(frame, &form))?;
|
|
||||||
|
|
||||||
if let Some(rx) = &attempt {
|
if let Some(rx) = &attempt {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(Ok(client)) => {
|
Ok(Ok(client)) => {
|
||||||
|
let entered_via_form = initial_username.as_deref()
|
||||||
|
!= Some(form.username.as_str())
|
||||||
|
|| initial_password.as_deref()
|
||||||
|
!= Some(form.password.as_str());
|
||||||
return Ok(Some(LoginOutcome {
|
return Ok(Some(LoginOutcome {
|
||||||
client,
|
client,
|
||||||
username: form.username,
|
username: form.username,
|
||||||
@@ -178,8 +213,8 @@ pub fn run_login_flow(
|
|||||||
entered_via_form,
|
entered_via_form,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Ok(Err(message)) => {
|
Ok(Err(failure)) => {
|
||||||
form.phase = LoginPhase::Failed(message);
|
form.phase = failure;
|
||||||
attempt = None;
|
attempt = None;
|
||||||
}
|
}
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||||
@@ -191,19 +226,24 @@ pub fn run_login_flow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let spinner =
|
||||||
|
get_spinner_char(started.elapsed().subsec_millis() as usize / 100);
|
||||||
|
terminal.draw(|frame| render(frame, &form, spinner))?;
|
||||||
|
|
||||||
if poll(Duration::from_millis(100))?
|
if poll(Duration::from_millis(100))?
|
||||||
&& let Event::Key(key) = event::read()?
|
&& let Event::Key(key) = event::read()?
|
||||||
&& key.kind == KeyEventKind::Press
|
&& key.kind == KeyEventKind::Press
|
||||||
{
|
{
|
||||||
match form.handle_key(key) {
|
match form.handle_key(key) {
|
||||||
LoginAction::Cancel => return Ok(None),
|
LoginAction::Cancel => return Ok(None),
|
||||||
|
LoginAction::Submit
|
||||||
|
if attempt.is_some()
|
||||||
|
&& started.elapsed() < RESTART_FLOOR => {}
|
||||||
LoginAction::Submit => {
|
LoginAction::Submit => {
|
||||||
entered_via_form = true;
|
attempt = Some(start(&form));
|
||||||
attempt = Some(spawn_attempt(make_settings(
|
started = Instant::now();
|
||||||
form.username.clone(),
|
|
||||||
form.password.clone(),
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
LoginAction::Stop => attempt = None,
|
||||||
LoginAction::None => {}
|
LoginAction::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,27 +251,35 @@ pub fn run_login_flow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connect and log in on a background thread so the UI stays responsive.
|
/// Connect and log in on a background thread so the UI stays responsive.
|
||||||
fn spawn_attempt(settings: ClientSettings) -> Receiver<Result<Client, String>> {
|
fn spawn_attempt(
|
||||||
|
settings: ClientSettings,
|
||||||
|
) -> Receiver<Result<Client, LoginPhase>> {
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut client = Client::with_settings(settings);
|
let mut client = Client::with_settings(settings);
|
||||||
let result = client
|
let result = client
|
||||||
.connect()
|
.connect()
|
||||||
.map_err(|e| format!("Failed to connect: {e}"))
|
.map_err(|e| LoginPhase::Failed(format!("Failed to connect: {e}")))
|
||||||
.and_then(|()| match client.login() {
|
.and_then(|()| verdict(client.login()));
|
||||||
Ok(true) => Ok(()),
|
|
||||||
Ok(false) => Err(
|
|
||||||
"Login rejected: wrong password, or the username is taken"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
Err(e) => Err(format!("Login failed: {e}")),
|
|
||||||
});
|
|
||||||
let _ = tx.send(result.map(|()| client));
|
let _ = tx.send(result.map(|()| client));
|
||||||
});
|
});
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(frame: &mut Frame, form: &LoginForm) {
|
fn verdict(login: soulseek_rs::Result<bool>) -> Result<(), LoginPhase> {
|
||||||
|
match login {
|
||||||
|
Ok(true) => Ok(()),
|
||||||
|
Ok(false) | Err(SoulseekRs::AuthenticationFailed) => {
|
||||||
|
Err(LoginPhase::Rejected(
|
||||||
|
"Login rejected: wrong password, or the username is taken"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) {
|
||||||
let area = centered(frame.area(), 52, 12);
|
let area = centered(frame.area(), 52, 12);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
@@ -290,21 +338,32 @@ fn render(frame: &mut Frame, form: &LoginForm) {
|
|||||||
let status = match &form.phase {
|
let status = match &form.phase {
|
||||||
LoginPhase::Editing => Paragraph::new(""),
|
LoginPhase::Editing => Paragraph::new(""),
|
||||||
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
||||||
"Connecting…",
|
format!("{spinner} Connecting…"),
|
||||||
warning_style(),
|
warning_style(),
|
||||||
))),
|
))),
|
||||||
LoginPhase::Failed(message) => Paragraph::new(Line::from(
|
LoginPhase::Failed(message) | LoginPhase::Rejected(message) => {
|
||||||
Span::styled(message.clone(), error_style()),
|
Paragraph::new(Line::from(Span::styled(
|
||||||
))
|
message.clone(),
|
||||||
.wrap(ratatui::widgets::Wrap { trim: true }),
|
error_style(),
|
||||||
|
)))
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||||
|
}
|
||||||
};
|
};
|
||||||
frame.render_widget(status, rows[3]);
|
frame.render_widget(status, rows[3]);
|
||||||
|
|
||||||
|
let keys = match form.phase {
|
||||||
|
LoginPhase::Connecting => {
|
||||||
|
"Enter/r: restart · Esc: stop · q/Ctrl-C: quit"
|
||||||
|
}
|
||||||
|
LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit",
|
||||||
|
LoginPhase::Editing | LoginPhase::Rejected(_) => {
|
||||||
|
"Tab: switch · Enter: log in · Esc: quit"
|
||||||
|
}
|
||||||
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(
|
Paragraph::new(format!(
|
||||||
"New usernames are registered automatically.\n\
|
"New usernames are registered automatically.\n{keys}"
|
||||||
Tab: switch · Enter: log in · Esc: quit",
|
))
|
||||||
)
|
|
||||||
.style(dimmed_style()),
|
.style(dimmed_style()),
|
||||||
rows[5],
|
rows[5],
|
||||||
);
|
);
|
||||||
@@ -323,7 +382,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use ratatui::crossterm::event::KeyModifiers;
|
|
||||||
|
|
||||||
fn key(code: KeyCode) -> KeyEvent {
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
KeyEvent::new(code, KeyModifiers::NONE)
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
@@ -335,6 +393,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connecting() -> LoginForm {
|
||||||
|
let mut form = LoginForm::new(Some("alice".into()));
|
||||||
|
type_str(&mut form, "pw");
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit);
|
||||||
|
form
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefilled_username_focuses_password() {
|
fn prefilled_username_focuses_password() {
|
||||||
let form = LoginForm::new(Some("alice".into()));
|
let form = LoginForm::new(Some("alice".into()));
|
||||||
@@ -402,11 +467,93 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_after_failure_returns_to_editing_and_clears_password() {
|
fn ctrl_c_quits_from_every_phase_without_typing() {
|
||||||
let mut form = LoginForm::new(Some("alice".into()));
|
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||||
type_str(&mut form, "wrong");
|
for phase in [
|
||||||
form.handle_key(key(KeyCode::Enter));
|
LoginPhase::Editing,
|
||||||
form.phase = LoginPhase::Failed("INVALIDPASS".into());
|
LoginPhase::Connecting,
|
||||||
|
LoginPhase::Failed("unreachable".into()),
|
||||||
|
LoginPhase::Rejected("INVALIDPASS".into()),
|
||||||
|
] {
|
||||||
|
let mut form = LoginForm::new(None);
|
||||||
|
form.phase = phase.clone();
|
||||||
|
assert_eq!(
|
||||||
|
form.handle_key(ctrl_c),
|
||||||
|
LoginAction::Cancel,
|
||||||
|
"{phase:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(form.username, "", "{phase:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_is_ignored_while_connecting() {
|
||||||
|
let mut form = connecting();
|
||||||
|
let action = form.handle_key(key(KeyCode::Char('x')));
|
||||||
|
assert_eq!(action, LoginAction::None);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_while_connecting_stops_and_keeps_the_credentials() {
|
||||||
|
let mut form = connecting();
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_or_r_while_connecting_restarts() {
|
||||||
|
for code in [KeyCode::Enter, KeyCode::Char('r')] {
|
||||||
|
let mut form = connecting();
|
||||||
|
assert_eq!(form.handle_key(key(code)), LoginAction::Submit);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn q_while_connecting_quits() {
|
||||||
|
let mut form = connecting();
|
||||||
|
assert_eq!(
|
||||||
|
form.handle_key(key(KeyCode::Char('q'))),
|
||||||
|
LoginAction::Cancel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() {
|
||||||
|
for code in [KeyCode::Enter, KeyCode::Char('r')] {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Failed("Login failed: timed out".into());
|
||||||
|
assert_eq!(form.handle_key(key(code)), LoginAction::Submit);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_key_after_a_failed_connection_edits_with_the_password_kept() {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Failed("Login failed: timed out".into());
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
|
assert_eq!(form.password, "pw");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_after_a_failed_connection_quits() {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Failed("Login failed: timed out".into());
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_after_rejection_returns_to_editing_and_clears_password() {
|
||||||
|
let mut form = connecting();
|
||||||
|
form.phase = LoginPhase::Rejected("INVALIDPASS".into());
|
||||||
let action = form.handle_key(key(KeyCode::Char('x')));
|
let action = form.handle_key(key(KeyCode::Char('x')));
|
||||||
assert_eq!(action, LoginAction::None);
|
assert_eq!(action, LoginAction::None);
|
||||||
assert_eq!(form.phase, LoginPhase::Editing);
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
@@ -414,13 +561,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_ignored_while_connecting() {
|
fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() {
|
||||||
let mut form = LoginForm::new(Some("alice".into()));
|
assert_eq!(verdict(Ok(true)), Ok(()));
|
||||||
type_str(&mut form, "pw");
|
for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] {
|
||||||
form.handle_key(key(KeyCode::Enter));
|
assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_))));
|
||||||
assert_eq!(form.phase, LoginPhase::Connecting);
|
}
|
||||||
let action = form.handle_key(key(KeyCode::Char('x')));
|
for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)]
|
||||||
assert_eq!(action, LoginAction::None);
|
{
|
||||||
assert_eq!(form.password, "pw");
|
assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user