use crossterm::event::MouseEventKind;
use ratatui::{backend::TestBackend, layout::Rect, Terminal};
use rho_providers::auth::login_prompt::LoginPrompt;
use rho_providers::model::catalog::LoginTarget;
use super::super::{theme::Theme, ComposerMode, PendingLoginComposer};
use super::login_composer_view;
use crate::tui::tests::test_app;
fn pending(prompt: LoginPrompt) -> PendingLoginComposer {
PendingLoginComposer {
target: LoginTarget {
provider: "openai-codex".into(),
auth: "codex".into(),
label: "Codex".into(),
},
prompt,
}
}
fn line_text(line: &ratatui::text::Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn device_pending() -> PendingLoginComposer {
pending(LoginPrompt::device_code(
"https://auth.example/device",
"WD4E-T6MC",
Some("https://auth.example/device?user_code=WD4E-T6MC".into()),
"Visit this URL and enter the code.",
))
}
#[test]
fn pending_login_composer_includes_url_and_code() {
let pending = device_pending();
let view = login_composer_view(&pending, 80, false);
let texts: Vec<String> = view.lines.iter().map(line_text).collect();
assert!(
texts
.iter()
.any(|line| line.contains("https://auth.example/device")),
"{texts:?}"
);
assert!(
texts.iter().any(|line| line.contains("WD4E-T6MC")),
"{texts:?}"
);
assert!(view.copy_hit.is_some(), "{texts:?}");
}
#[test]
fn copy_hit_uses_painted_origin_for_scrolled_session_and_setup() {
let pending = device_pending();
let view = login_composer_view(&pending, 80, false);
let hit = view.copy_hit.expect("copy button row");
let copy_col = (hit.columns.start + 1) as u16;
let url = "https://auth.example/device?user_code=WD4E-T6MC";
let cases = [
(
"scrolled session",
Rect {
x: 0,
y: 10,
width: 80,
height: 4,
},
2,
),
(
"setup body offset",
Rect {
x: 6,
y: 9,
width: 80,
height: 12,
},
0,
),
];
for (label, origin, start) in cases {
let painted_row = origin
.y
.saturating_add(hit.row.saturating_sub(start) as u16);
pretty_assertions::assert_eq!(
hit.text_at(origin, start, origin.x + copy_col, painted_row),
Some(url),
"{label} painted origin"
);
pretty_assertions::assert_eq!(
hit.text_at(origin, start, copy_col, hit.row as u16),
None,
"{label} naive line-as-row must miss"
);
}
}
#[test]
fn transcript_notice_omits_device_code() {
let pending = device_pending();
let lines = super::notice_lines("Codex", &pending.prompt);
pretty_assertions::assert_eq!(
lines,
vec![
"https://auth.example/device".to_string(),
"Codex login pending".to_string(),
]
);
}
#[test]
fn session_copy_button_hits_composer_layout() {
let mut app = test_app();
app.input_ui
.set_composer(ComposerMode::InteractivePending(device_pending()));
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let ctx = app.frame_context(area);
let hit = ctx.composer.copy_hit.expect("copy button");
let column = ctx
.layout
.composer
.x
.saturating_add(hit.columns.start as u16 + 1);
let row = ctx
.layout
.composer
.y
.saturating_add(hit.row.saturating_sub(ctx.layout.composer_start) as u16);
pretty_assertions::assert_eq!(
app.composer_copy_text_at(area, column, row).as_deref(),
Some("https://auth.example/device?user_code=WD4E-T6MC")
);
}
fn copy_span_style(
lines: &[ratatui::text::Line<'_>],
hit: &crate::tui::copy_interaction::CopyHit,
) -> ratatui::style::Style {
lines
.get(hit.row)
.and_then(|line| line.spans.last())
.expect("copy span")
.style
}
#[test]
fn copy_button_span_follows_hovered_flag() {
let pending = device_pending();
for hovered in [false, true] {
let view = login_composer_view(&pending, 80, hovered);
let hit = view.copy_hit.as_ref().expect("copy button");
pretty_assertions::assert_eq!(
copy_span_style(&view.lines, hit),
Theme::markdown_code_copy_button(hovered),
"hovered={hovered}"
);
}
}
#[test]
fn session_copy_button_hover_follows_pointer() {
let mut app = test_app();
app.input_ui
.set_composer(ComposerMode::InteractivePending(device_pending()));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let ctx = app.frame_context(area);
let hit = ctx.composer.copy_hit.clone().expect("copy button");
let column = ctx
.layout
.composer
.x
.saturating_add(hit.columns.start as u16 + 1);
let on_row = ctx
.layout
.composer
.y
.saturating_add(hit.row.saturating_sub(ctx.layout.composer_start) as u16);
let off_column = ctx.layout.composer.x;
let off_row = ctx.layout.composer.y;
let width = ctx.layout.composer.width as usize;
let height = ctx.layout.composer.height as usize;
app.handle_mouse_event(MouseEventKind::Moved, column, on_row, &mut terminal)
.unwrap();
pretty_assertions::assert_eq!(app.input_ui.hovered_composer_copy(), true);
pretty_assertions::assert_eq!(
copy_span_style(&app.composer_frame(width, height).lines, &hit),
Theme::markdown_code_copy_button( true)
);
app.handle_mouse_event(MouseEventKind::Moved, off_column, off_row, &mut terminal)
.unwrap();
pretty_assertions::assert_eq!(app.input_ui.hovered_composer_copy(), false);
pretty_assertions::assert_eq!(
copy_span_style(&app.composer_frame(width, height).lines, &hit),
Theme::markdown_code_copy_button( false)
);
}
#[test]
fn login_copy_hover_clears_when_pending_login_ends() {
let mut app = test_app();
app.input_ui
.set_composer(ComposerMode::InteractivePending(device_pending()));
app.input_ui.set_hovered_composer_copy(true);
app.input_ui.set_composer(ComposerMode::Input);
pretty_assertions::assert_eq!(app.input_ui.hovered_composer_copy(), false);
}