use crossterm::event::{MouseButton, MouseEventKind};
use pretty_assertions::assert_eq;
use ratatui::{backend::TestBackend, layout::Rect, Terminal};
use rho_sdk::{DefaultSelection, HostChoice, HostInputRequest, HostQuestion, SelectionMode};
use super::{composer_target_at, lift_hovered_hit, ComposerChoice, ComposerHit};
use crate::tui::{
approval::ApprovalChoice,
questionnaire::{questionnaire_frame, QuestionnaireComposer, QuestionnaireTarget},
tests::test_app,
ComposerMode, QuestionnaireResponseChannel,
};
#[test]
fn pointer_maps_through_origin_and_visible_start() {
let hits = [
ComposerHit::rows(2..4, 'a'),
ComposerHit {
lines: 4..5,
columns: 3..6,
target: 'b',
active: false,
},
];
let origin = Rect::new(2, 10, 20, 3);
let cases = [
(0, 2, 12, Some('a')),
(0, 2, 13, None),
(1, 2, 11, Some('a')),
(1, 2, 12, Some('a')),
(2, 5, 12, Some('b')),
(2, 8, 12, None),
(0, 1, 12, None),
(0, 2, 9, None),
];
for (start, column, row, expected) in cases {
assert_eq!(
composer_target_at(&hits, origin, start, column, row),
expected,
"start={start} column={column} row={row}"
);
}
}
fn composer(questions: Vec<HostQuestion>) -> QuestionnaireComposer {
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
QuestionnaireComposer::new(
HostInputRequest::questionnaire("", questions).unwrap(),
QuestionnaireResponseChannel::new(reply_tx),
)
}
#[test]
fn choice_hits_tile_each_choice_block_in_order() {
let long = "a label long enough to wrap onto a second row at this width";
let question = |selection| {
HostQuestion::new(
"q",
"Pick one?",
vec![
HostChoice::new("short", "short"),
HostChoice::new("desc", "described").description("with a description row"),
HostChoice::new("long", long),
],
selection,
)
.unwrap()
.default_value(serde_json::json!("long"))
.default_selection(DefaultSelection::Focused)
.allow_other()
};
let width = 30;
for (name, selection, type_other, expected_confirms) in [
(
"single",
SelectionMode::One,
false,
[true, true, true, false],
),
(
"multi with other text",
SelectionMode::Many,
true,
[false; 4],
),
] {
let mut composer = composer(vec![question(selection)]);
if type_other {
assert!(composer.insert_text("typed other text that also wraps around"));
}
let frame = questionnaire_frame(&composer, width);
let choices: Vec<_> = frame
.hits
.iter()
.filter_map(|hit| match hit.target {
QuestionnaireTarget::Choice { index, confirms } => {
Some((index, confirms, hit.lines.clone()))
}
QuestionnaireTarget::Question(_) => None,
})
.collect();
let row_text = |row: usize| -> String {
frame.lines[row]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
};
assert_eq!(
choices.iter().map(|(index, ..)| *index).collect::<Vec<_>>(),
vec![0, 1, 2, 3],
"{name}"
);
for pair in choices.windows(2) {
assert_eq!(pair[0].2.end, pair[1].2.start, "{name}");
}
for (index, _, lines) in &choices {
let first = row_text(lines.start);
assert!(
first.contains('○')
|| first.contains('●')
|| first.contains('□')
|| first.contains('■'),
"{name}: choice {index} starts on a non-marker row: {first:?}"
);
for row in lines.clone().skip(1) {
let text = row_text(row);
assert!(
!(text.contains('○')
|| text.contains('●')
|| text.contains('□')
|| text.contains('■')),
"{name}: choice {index} swallowed another marker row: {text:?}"
);
}
}
assert!(
choices.iter().any(|(_, _, lines)| lines.len() > 1),
"{name}: fixture should produce multi-row choices"
);
let confirms: Vec<_> = choices.iter().map(|(_, confirms, _)| *confirms).collect();
assert_eq!(confirms, expected_confirms, "{name}");
}
}
#[test]
fn tab_chip_hits_cover_their_painted_labels() {
let questions = (1..=4)
.map(|index| {
HostQuestion::new(
format!("q{index}"),
format!("question number {index}?"),
vec![HostChoice::new("a", "a"), HostChoice::new("b", "b")],
SelectionMode::One,
)
.unwrap()
})
.collect();
let mut composer = composer(questions);
composer.focus_question(3);
let frame = questionnaire_frame(&composer, 40);
let mut tabs = Vec::new();
for hit in &frame.hits {
let QuestionnaireTarget::Question(index) = hit.target else {
continue;
};
let line: String = frame.lines[hit.lines.start]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let painted: String = line
.chars()
.skip(hit.columns.start)
.take(hit.columns.len())
.collect();
tabs.push((index, painted.split_whitespace().next().map(str::to_owned)));
}
assert!(
tabs.len() < 4,
"tab bar should scroll at this width: {tabs:?}"
);
assert!(tabs.iter().any(|(index, _)| *index == 3));
for (index, label) in tabs {
assert_eq!(label, Some((index + 1).to_string()));
}
}
#[tokio::test]
async fn approval_clicks_wait_for_the_prompt_to_be_painted() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let request = rho_sdk::ApprovalRequest::new(
rho_sdk::CapabilityRequest::read_path(
"/workspace/file",
rho_sdk::PathScope::PrimaryWorkspace,
rho_sdk::CapabilitySource::built_in_tool("read_file"),
),
"approval required",
);
app.open_approval(rho_sdk::PendingApproval::new(request).0)
.await;
let active = |app: &crate::tui::App| match app.input_ui.composer() {
ComposerMode::Approval(approval) => approval.active(),
other => panic!("approval closed: {other:?}"),
};
let allow_once = |app: &mut crate::tui::App| {
let ctx = app.frame_context(Rect::new(0, 0, 80, 24));
let hit = ctx
.composer
.choice_hits
.iter()
.find(|hit| hit.target == ComposerChoice::Approval(ApprovalChoice::AllowOnce))
.expect("allow once is painted");
let row = ctx.layout.composer.y + (hit.lines.start - ctx.layout.composer_start) as u16;
(ctx.layout.composer.x, row)
};
let (column, row) = allow_once(&mut app);
app.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
column,
row,
&mut terminal,
)
.unwrap();
assert_eq!(active(&app), ApprovalChoice::Deny);
terminal.draw(|frame| app.draw(frame)).unwrap();
let (column, row) = allow_once(&mut app);
app.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
column,
row,
&mut terminal,
)
.unwrap();
assert_eq!(active(&app), ApprovalChoice::AllowOnce);
}
fn sectioned_item(section: &str, label: String) -> crate::tui::PickerItem {
crate::tui::PickerItem {
section: Some(section.into()),
value: label.clone(),
label,
detail: None,
preview: None,
badge: None,
selection_verb: None,
allow_filter_completion: true,
}
}
#[test]
fn inline_picker_hits_name_absolute_items_and_skip_headers() {
let items: Vec<_> = (0..12)
.map(|index| {
let section = if index < 6 { "ALPHA" } else { "BETA" };
sectioned_item(section, format!("{}-{index:02}", section.to_lowercase()))
})
.collect();
let cases = [
("scrolled past the first section", "", 10, Some(0)),
("filtered to the second section", "beta", 11, None),
];
for (name, filter, selected, hidden) in cases {
let mut picker = crate::tui::UiPicker::config("sections", items.clone());
picker.filter = filter.into();
picker.selected = selected;
let mut app = test_app();
app.input_ui.set_composer(ComposerMode::Picker(picker));
let frame = app.composer_frame(80, 20);
let text = |line: usize| -> String {
frame.lines[line]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
};
let hits: Vec<_> = frame
.choice_hits
.iter()
.map(|hit| match hit.target {
ComposerChoice::PickerRow(target) => (target.item, hit.lines.clone()),
ComposerChoice::Questionnaire(_)
| ComposerChoice::Approval(_)
| ComposerChoice::InlineChoice(_) => {
panic!("{name}: foreign target {:?}", hit.target)
}
})
.collect();
assert!(
hits.iter().any(|(item, _)| *item == selected),
"{name}: selection painted"
);
if let Some(hidden) = hidden {
assert!(
hits.iter().all(|(item, _)| *item != hidden),
"{name}: window scrolled"
);
}
for (item, lines) in &hits {
assert_eq!(lines.len(), 1, "{name}");
let row = text(lines.start);
assert!(
row.contains(&items[*item].label),
"{name}: item {item} hit lands on {row:?}"
);
}
let header_rows: Vec<usize> = (0..frame.lines.len())
.filter(|line| matches!(text(*line).trim(), "ALPHA" | "BETA"))
.collect();
assert!(!header_rows.is_empty(), "{name}: fixture paints a header");
for line in header_rows {
assert!(
hits.iter().all(|(_, lines)| !lines.contains(&line)),
"{name}: header row {line} is clickable"
);
}
}
}
#[test]
fn hover_lifts_only_the_hovered_inactive_rows() {
use ratatui::{
buffer::Buffer,
style::{Color, Style},
};
let origin = Rect::new(0, 0, 6, 3);
let hits = [
ComposerHit::rows(0..1, 'a').with_active(true),
ComposerHit::rows(1..3, 'b'),
ComposerHit::rows(3..4, 'c'),
];
let painted = {
let mut buffer = Buffer::empty(origin);
buffer.set_style(origin, Style::default().fg(Color::Rgb(90, 90, 90)));
buffer
};
let lifted_rows = |start: usize, pointer: Option<(u16, u16)>| {
let mut buffer = painted.clone();
lift_hovered_hit(&mut buffer, &hits, origin, start, pointer);
(0..origin.height)
.filter(|&y| buffer[(0, y)] != painted[(0, y)])
.collect::<Vec<_>>()
};
let cases = [
("inactive two-row target", 0, Some((2, 2)), vec![1, 2]),
("active target stays as painted", 0, Some((2, 0)), vec![]),
(
"scrolled: target clipped to the window",
1,
Some((2, 0)),
vec![0, 1],
),
("pointer off every target", 0, None, vec![]),
];
for (name, start, pointer, expected) in cases {
assert_eq!(lifted_rows(start, pointer), expected, "{name}");
}
}