use std::collections::VecDeque;
use std::error as path_std_error;
use std::io::{self, Cursor};
use std::sync::{Arc, Mutex};
use crate::key::{PickerEvent, PickerKey};
use crate::raw_mode::RawModeCleanup;
use crate::{
NavigationDirection, PickerError, PickerItem, adjacent_enabled_item, pick_with_event_reader,
pick_with_io, pick_with_raw_mode, picker_lines,
};
struct InterruptedReader {
interrupted: bool,
inner: Cursor<Vec<u8>>,
}
impl InterruptedReader {
fn new(bytes: &[u8]) -> Self {
Self {
interrupted: false,
inner: Cursor::new(bytes.to_vec()),
}
}
}
impl io::Read for InterruptedReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if !self.interrupted {
self.interrupted = true;
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
self.inner.read(buf)
}
}
fn items(labels: &[&str]) -> Vec<PickerItem> {
labels.iter().map(|l| PickerItem::enabled(*l)).collect()
}
fn run(reader_bytes: &[u8], items: &[PickerItem]) -> Result<usize, PickerError> {
let writer = Vec::<u8>::new();
let reader = Cursor::new(reader_bytes.to_vec());
pick_with_io("pick", items, writer, reader)
}
#[test]
fn interrupted_byte_read_is_retried() {
let it = items(&["one", "two"]);
let picked = pick_with_io("pick", &it, Vec::<u8>::new(), InterruptedReader::new(b"\n"))
.expect("interrupted read should be retried before enter");
assert_eq!(picked, 0);
}
#[test]
fn enter_selects_first_enabled() {
let it = items(&["one", "two"]);
assert_eq!(run(b"\n", &it).expect("enter picks 0"), 0);
}
#[test]
fn cr_also_selects() {
let it = items(&["one"]);
assert_eq!(run(b"\r", &it).expect("cr picks 0"), 0);
}
#[test]
fn space_does_not_select() {
let it = items(&["one", "two"]);
assert!(matches!(run(b" ", &it), Err(PickerError::Cancelled)));
}
#[test]
fn j_moves_down_k_moves_up() {
let it = items(&["a", "b", "c"]);
assert_eq!(run(b"jj\n", &it).expect("jj enter"), 2);
assert_eq!(run(b"jjk\n", &it).expect("jjk enter"), 1);
}
#[test]
fn arrow_keys_move() {
let it = items(&["a", "b", "c"]);
assert_eq!(run(b"\x1b[B\x1b[B\n", &it).expect("two downs"), 2);
assert_eq!(
run(b"\x1b[B\x1b[B\x1b[A\n", &it).expect("two downs one up"),
1
);
}
#[test]
fn tab_moves_down_backtab_moves_up() {
let it = items(&["a", "b", "c"]);
assert_eq!(run(b"\t\t\n", &it).expect("two tabs"), 2);
assert_eq!(run(b"\t\t\x1b[Z\n", &it).expect("two tabs one backtab"), 1);
}
#[test]
fn ctrl_c_cancels() {
let it = items(&["a", "b"]);
assert!(matches!(run(b"\x03", &it), Err(PickerError::Cancelled)));
}
#[test]
fn ctrl_d_cancels() {
let it = items(&["a", "b"]);
assert!(matches!(run(b"\x04", &it), Err(PickerError::Cancelled)));
}
#[test]
fn q_cancels() {
let it = items(&["a", "b"]);
assert!(matches!(run(b"q", &it), Err(PickerError::Cancelled)));
}
#[test]
fn bare_esc_cancels() {
let it = items(&["a", "b"]);
assert!(matches!(run(b"\x1b", &it), Err(PickerError::Cancelled)));
}
#[test]
fn esc_then_unrelated_byte_cancels() {
let it = items(&["a", "b"]);
assert!(matches!(run(b"\x1bx", &it), Err(PickerError::Cancelled)));
}
#[test]
fn empty_items_errors() {
let it: Vec<PickerItem> = Vec::new();
assert!(matches!(run(b"\n", &it), Err(PickerError::Empty)));
}
#[test]
fn all_disabled_errors() {
let it = vec![PickerItem::disabled("a"), PickerItem::disabled("b")];
assert!(matches!(run(b"\n", &it), Err(PickerError::NoEnabledItems)));
}
#[test]
fn io_error_exposes_source() {
let err = PickerError::Io(io::Error::other("synthetic io error"));
let source = path_std_error::Error::source(&err).expect("io source should be exposed");
assert_eq!(source.to_string(), "synthetic io error");
}
#[test]
fn raw_mode_picker_validates_items_before_enabling_raw_mode() {
let mut raw_enabled = false;
let result = pick_with_raw_mode(
"pick",
&[],
Vec::<u8>::new(),
|| {
raw_enabled = true;
Ok(FailingRawModeGuard {
restore_error: None,
})
},
|| panic!("invalid items should not read input"),
|| panic!("invalid items should not sample terminal size"),
);
assert!(matches!(result, Err(PickerError::Empty)));
assert!(!raw_enabled, "raw mode should not be enabled");
let mut raw_enabled = false;
let disabled = vec![PickerItem::disabled("a")];
let result = pick_with_raw_mode(
"pick",
&disabled,
Vec::<u8>::new(),
|| {
raw_enabled = true;
Ok(FailingRawModeGuard {
restore_error: None,
})
},
|| panic!("invalid items should not read input"),
|| panic!("invalid items should not sample terminal size"),
);
assert!(matches!(result, Err(PickerError::NoEnabledItems)));
assert!(!raw_enabled, "raw mode should not be enabled");
}
#[test]
fn raw_mode_setup_failure_short_circuits_picker() {
let it = items(&["one"]);
let err = pick_with_raw_mode(
"pick",
&it,
Vec::<u8>::new(),
|| Err::<FailingRawModeGuard, _>(io::Error::other("synthetic raw setup error")),
|| panic!("raw-mode setup failure should not read input"),
|| panic!("raw-mode setup failure should not sample terminal size"),
)
.expect_err("raw-mode setup failure should propagate");
match err {
PickerError::Io(source) => assert_eq!(source.to_string(), "synthetic raw setup error"),
other => panic!("expected raw setup IO error, got {other:?}"),
}
}
#[test]
fn disabled_items_are_skipped() {
let it = vec![
PickerItem::enabled("a"),
PickerItem::disabled("b"),
PickerItem::enabled("c"),
];
assert_eq!(run(b"j\n", &it).expect("skip disabled"), 2);
assert_eq!(run(b"jj\n", &it).expect("skip disabled twice"), 0);
}
#[test]
fn adjacent_enabled_item_preserves_navigation_direction_behavior() {
let with_disabled_middle = vec![
PickerItem::enabled("a"),
PickerItem::disabled("b"),
PickerItem::enabled("c"),
];
assert_eq!(
adjacent_enabled_item(&with_disabled_middle, 0, NavigationDirection::Forward),
2,
"forward skips a disabled row",
);
assert_eq!(
adjacent_enabled_item(&with_disabled_middle, 2, NavigationDirection::Backward),
0,
"backward skips a disabled row",
);
assert_eq!(
adjacent_enabled_item(&with_disabled_middle, 2, NavigationDirection::Forward),
0,
"forward wraps",
);
assert_eq!(
adjacent_enabled_item(&with_disabled_middle, 0, NavigationDirection::Backward),
2,
"backward wraps",
);
let only_enabled = vec![
PickerItem::disabled("a"),
PickerItem::enabled("b"),
PickerItem::disabled("c"),
];
assert_eq!(
adjacent_enabled_item(&only_enabled, 1, NavigationDirection::Forward),
1,
"forward retains the sole enabled selection",
);
assert_eq!(
adjacent_enabled_item(&only_enabled, 1, NavigationDirection::Backward),
1,
"backward retains the sole enabled selection",
);
}
#[test]
fn first_enabled_is_initial_selection() {
let it = vec![
PickerItem::disabled("a"),
PickerItem::disabled("b"),
PickerItem::enabled("c"),
];
assert_eq!(run(b"\n", &it).expect("third is enabled"), 2);
}
#[test]
fn byte_reader_ignores_unknown_chars() {
let it = items(&["a", "b"]);
assert_eq!(run(b"xy\n", &it).expect("unknown then enter"), 0);
}
#[test]
fn visible_window_centers_selection() {
use crate::visible_window;
assert_eq!(visible_window(5, 2, 10), 0..5);
assert_eq!(visible_window(20, 0, 5), 0..5);
assert_eq!(visible_window(20, 10, 5), 8..13);
assert_eq!(visible_window(20, 19, 5), 15..20);
assert_eq!(visible_window(20, 10, 1), 10..11);
}
fn line_text(line: &[tau_term_screen::style::Cell]) -> String {
line.iter().map(|cell| cell.ch).collect()
}
#[test]
fn one_row_terminal_uses_compact_frame() {
let it = items(&["one", "two"]);
let (lines, cursor_row) = picker_lines("pick", &it, 1, 80, 1);
assert_eq!(lines.len(), 1);
assert_eq!(cursor_row, 0);
assert_eq!(line_text(&lines[0]), "> two — ? pick");
}
#[test]
fn compact_frame_prioritizes_selected_item_when_truncated() {
let it = items(&["one", "selected-item"]);
let (lines, cursor_row) = picker_lines("very long prompt", &it, 1, 8, 1);
assert_eq!(cursor_row, 0);
assert_eq!(line_text(&lines[0]), "> selec…");
}
#[test]
fn normal_terminal_uses_prompt_plus_items() {
let it = vec![
PickerItem::enabled("ordinary"),
PickerItem::disabled("unavailable"),
PickerItem::enabled("selected label"),
];
let (lines, cursor_row) = picker_lines("pick", &it, 2, 12, 4);
assert_eq!(cursor_row, 3);
assert_eq!(
lines.iter().map(|line| line_text(line)).collect::<Vec<_>>(),
["? pick", " ordinary", "X unavailab…", "> selected …"]
);
}
#[derive(Clone, Default)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl SharedWriter {
fn bytes(&self) -> Vec<u8> {
self.0.lock().expect("writer buffer poisoned").clone()
}
}
impl io::Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.expect("writer buffer poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct FailingRawModeGuard {
restore_error: Option<io::Error>,
}
impl RawModeCleanup for FailingRawModeGuard {
fn restore_raw_mode(&mut self) -> io::Result<()> {
match self.restore_error.take() {
Some(err) => Err(err),
None => Ok(()),
}
}
}
struct FailsAfterFirstFlush {
flushed_once: bool,
}
impl io::Write for FailsAfterFirstFlush {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.flushed_once {
Err(io::Error::other("synthetic cleanup error"))
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> io::Result<()> {
self.flushed_once = true;
Ok(())
}
}
#[test]
fn resize_event_redraws_without_waiting_for_key_resample() {
let it = items(&["very long item label"]);
let writer = SharedWriter::default();
let output = writer.clone();
let mut events = VecDeque::from([
PickerEvent::Resize {
width: 8,
height: 3,
},
PickerEvent::Key(PickerKey::Enter),
]);
let mut size_samples = 0;
let picked = pick_with_event_reader(
"choose a thing",
&it,
writer,
|| Ok(events.pop_front().expect("test event available")),
|| {
size_samples += 1;
(40, 5)
},
)
.expect("picker should accept after resize");
assert_eq!(picked, 0);
assert_eq!(
size_samples, 1,
"resize events should use their reported dimensions rather than resampling"
);
let bytes = output.bytes();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("? choos…") && text.contains("> very …"),
"resized redraw should contain the narrow prompt and item rows: {text:?}"
);
}
#[test]
fn zero_resize_keeps_current_size_without_resampling_terminal() {
let it = items(&["first row", "second row"]);
let writer = SharedWriter::default();
let output = writer.clone();
let mut events = VecDeque::from([
PickerEvent::Resize {
width: 0,
height: 0,
},
PickerEvent::Key(PickerKey::Enter),
]);
let mut size_samples = 0;
let picked = pick_with_event_reader(
"distinct prompt",
&it,
writer,
|| Ok(events.pop_front().expect("test event available")),
|| {
size_samples += 1;
(12, 3)
},
)
.expect("picker should accept after zero-sized resize");
assert_eq!(picked, 0);
assert_eq!(
size_samples, 1,
"zero-sized resize should retain the initial terminal dimensions"
);
let bytes = output.bytes();
let text = String::from_utf8_lossy(&bytes);
assert_eq!(
text.matches("? distinct …").count(),
2,
"initial and zero-resize frames should both use the original width and height: {text:?}"
);
assert_eq!(
text.matches(" second row").count(),
2,
"initial and zero-resize frames should both retain the second item row: {text:?}"
);
}
#[test]
fn picker_clears_frame_on_user_cancel() {
let it = items(&["one", "two"]);
let writer = SharedWriter::default();
let output = writer.clone();
let err = pick_with_event_reader(
"pick",
&it,
writer,
|| Ok(PickerEvent::Key(PickerKey::Cancelled)),
|| (40, 5),
)
.expect_err("user cancellation should propagate");
assert!(matches!(err, PickerError::Cancelled));
let bytes = output.bytes();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("[J"), "cleanup should clear frame: {text:?}");
}
#[test]
fn cancel_cleanup_failure_does_not_replace_cancel_error() {
let it = items(&["one", "two"]);
let err = pick_with_event_reader(
"pick",
&it,
FailsAfterFirstFlush {
flushed_once: false,
},
|| Ok(PickerEvent::Key(PickerKey::Cancelled)),
|| (40, 5),
)
.expect_err("user cancellation should remain visible");
assert!(matches!(err, PickerError::Cancelled));
}
#[test]
fn input_cleanup_failure_does_not_replace_input_error() {
let it = items(&["one", "two"]);
let err = pick_with_event_reader(
"pick",
&it,
FailsAfterFirstFlush {
flushed_once: false,
},
|| Err(io::Error::other("synthetic input error")),
|| (40, 5),
)
.expect_err("input error should remain visible");
match err {
PickerError::Io(source) => assert_eq!(source.to_string(), "synthetic input error"),
other => panic!("expected input IO error, got {other:?}"),
}
}
#[test]
fn selection_cleanup_failure_is_reported() {
let it = items(&["one", "two"]);
let err = pick_with_event_reader(
"pick",
&it,
FailsAfterFirstFlush {
flushed_once: false,
},
|| Ok(PickerEvent::Key(PickerKey::Enter)),
|| (40, 5),
)
.expect_err("selection cleanup failure should be reported");
match err {
PickerError::Io(source) => assert_eq!(source.to_string(), "synthetic cleanup error"),
other => panic!("expected cleanup IO error, got {other:?}"),
}
}
#[test]
fn raw_mode_restore_failure_replaces_successful_selection() {
let it = items(&["one", "two"]);
let err = pick_with_raw_mode(
"pick",
&it,
Vec::<u8>::new(),
|| {
Ok(FailingRawModeGuard {
restore_error: Some(io::Error::other("synthetic raw restore error")),
})
},
|| Ok(PickerEvent::Key(PickerKey::Enter)),
|| (40, 5),
)
.expect_err("raw restore failure should be reported");
match err {
PickerError::Io(source) => assert_eq!(source.to_string(), "synthetic raw restore error"),
other => panic!("expected raw restore IO error, got {other:?}"),
}
}
#[test]
fn raw_mode_restore_failure_replaces_cancel_error() {
let it = items(&["one", "two"]);
let err = pick_with_raw_mode(
"pick",
&it,
Vec::<u8>::new(),
|| {
Ok(FailingRawModeGuard {
restore_error: Some(io::Error::other("synthetic raw restore error")),
})
},
|| Ok(PickerEvent::Key(PickerKey::Cancelled)),
|| (40, 5),
)
.expect_err("raw restore failure should be reported");
match err {
PickerError::Io(source) => assert_eq!(source.to_string(), "synthetic raw restore error"),
other => panic!("expected raw restore IO error, got {other:?}"),
}
}