use super::*;
#[test]
fn toggling_view_rebuilds_the_stale_list_after_an_edit() {
let cs = parse_report(DIFF).0;
let mut app = App::with_comments(cs, CommentStore::default());
app.wrap = true; app.comments.add_thread(
"f.rs".into(),
Side::New,
LineRange { start: 3, end: 3 },
Some("a".into()),
"hi".into(),
);
app.rebuild_rows();
assert!(
app.unified_dirty,
"a split-view edit must mark the unified list stale"
);
app.toggle_view();
assert!(matches!(app.view, View::Unified));
assert!(!app.unified_dirty, "toggle must clear the stale flag");
assert!(
app.rows
.iter()
.any(|r| matches!(&r.kind, RowKind::Comment(_))),
"the toggled-to view must show the comment added while it was stale"
);
render(&mut app, 80, 40);
app.toggle_view();
render(&mut app, 80, 40);
}
#[test]
fn width_sync_rewraps_threads_on_non_current_files() {
let cs = parse_report(TWO_FILES).0;
let mut store = CommentStore::default();
store.add_thread(
"two.rs".into(),
Side::New,
LineRange { start: 2, end: 2 },
Some("a".into()),
"this is a sufficiently long comment body that must wrap across \
several display lines when laid out at the real diff width"
.into(),
);
let mut app = App::with_comments(cs, store);
app.wrap = true;
assert_eq!(
app.current_file, 0,
"thread must live on a non-current file"
);
render(&mut app, 120, 40);
let body_widths: Vec<usize> = app
.split_rows
.iter()
.filter_map(|r| match &r.kind {
SplitRowKind::Comment {
line:
CommentLine {
kind: CommentKind::Body(b),
..
},
..
} if !b.is_empty() => Some(b.chars().count()),
_ => None,
})
.collect();
assert!(
!body_widths.is_empty(),
"the second file's thread body must be present in split_rows"
);
assert!(
body_widths.iter().any(|&w| w > 1),
"thread on a non-current file must wrap at the real width, not one \
char per line; got body line widths {body_widths:?}"
);
let full = build_split_rows(
&app.changeset,
&app.comments,
app.comment_wrap,
app.composer_spec().as_ref(),
);
assert_eq!(format!("{:?}", app.split_rows), format!("{full:?}"));
}
#[test]
fn incremental_rebuild_matches_full_rebuild() {
let assert_split_matches = |app: &App| {
let full = build_split_rows(
&app.changeset,
&app.comments,
app.comment_wrap,
app.composer_spec().as_ref(),
);
assert_eq!(format!("{:?}", app.split_rows), format!("{full:?}"));
};
let assert_unified_matches = |app: &App| {
let full = build_rows(
&app.changeset,
&app.comments,
app.comment_wrap,
app.composer_spec().as_ref(),
);
assert_eq!(format!("{:?}", app.rows), format!("{full:?}"));
};
let cs = parse_report(TWO_FILES).0;
let mut app = App::with_comments(cs, CommentStore::default());
app.wrap = false;
app.set_current_file(1); goto(&mut app, Side::New, 2);
app.open_new_thread();
assert_split_matches(&app);
for ch in "hello".chars() {
app.on_key_compose(KeyCode::Char(ch), KeyModifiers::NONE);
assert_split_matches(&app);
}
app.submit_compose();
assert_split_matches(&app);
app.toggle_view();
assert!(matches!(app.view, View::Unified));
app.open_reply();
for ch in "yo".chars() {
app.on_key_compose(KeyCode::Char(ch), KeyModifiers::NONE);
assert_unified_matches(&app);
}
app.submit_compose();
assert_unified_matches(&app);
}
#[test]
fn delete_targets_session_comments_only() {
let (mut app, tid, base_reply_id) = app_with_thread(3);
app.selected = comment_head(&app, &base_reply_id);
app.delete_current_comment();
assert_eq!(app.status, "can't delete a comment from the input");
assert_eq!(
app.comments.threads[0].comments.len(),
2,
"an input comment must survive D"
);
app.selected = comment_head(&app, &base_reply_id);
app.open_reply();
app.on_key_compose(KeyCode::Char('y'), KeyModifiers::NONE);
app.submit_compose();
assert_eq!(app.comments.threads[0].comments.len(), 3);
let new_reply_id = app.comments.threads[0].comments[2].id.clone();
app.selected = comment_head(&app, &new_reply_id);
app.delete_current_comment();
assert_eq!(app.status, "deleted comment");
assert_eq!(
app.comments.threads[0].comments.len(),
2,
"only the session reply is removed"
);
assert!(
app.comments.threads.iter().any(|t| t.id == tid),
"the thread (and its input comments) survives"
);
}
#[test]
fn deleting_a_session_thread_last_comment_drops_the_thread() {
let (mut app, _tid, _reply) = app_with_thread(3);
goto(&mut app, Side::New, 1);
app.open_new_thread();
app.on_key_compose(KeyCode::Char('x'), KeyModifiers::NONE);
app.submit_compose();
let new_tid = app
.comments
.threads
.iter()
.find(|t| t.range.contains(1) && t.side == Side::New)
.expect("new thread")
.id
.clone();
let cid = app
.comments
.threads
.iter()
.find(|t| t.id == new_tid)
.unwrap()
.comments[0]
.id
.clone();
app.selected = comment_head(&app, &cid);
app.delete_current_comment();
assert!(
!app.comments.threads.iter().any(|t| t.id == new_tid),
"emptying a thread drops it"
);
}
#[test]
fn comments_are_navigable_stops() {
let (mut app, _tid, reply_id) = app_with_thread(3);
goto(&mut app, Side::New, 3);
let mut comment_stops = 0;
for _ in 0..40 {
app.move_by(1, 1);
if app.comment_unit_at(app.selected).is_some() {
comment_stops += 1;
}
}
assert!(
comment_stops >= 2,
"navigation should stop on each comment message (got {comment_stops})"
);
let head = comment_head(&app, &reply_id);
assert!(app.is_stop_at(head));
}
#[test]
fn paste_inserts_into_composer_and_is_ignored_otherwise() {
let mut app = app_with(DIFF);
app.on_paste("qqq deletes nothing".into());
assert!(!app.quit);
assert!(app.composer.is_none());
open_composer(&mut app);
app.on_paste("first line\r\nsecond line\rthird".into());
assert_eq!(
app.composer.as_ref().unwrap().textarea.lines().join("\n"),
"first line\nsecond line\nthird"
);
assert!(app.composer.is_some(), "paste must not submit");
}
#[test]
fn resolved_thread_comment_shows_focus_border() {
let (mut app, tid, reply_id) = app_with_thread(3);
app.comments.toggle_resolved(&tid);
app.rebuild_rows();
assert!(app.comments.threads[0].resolved);
let head = comment_head(&app, &reply_id);
assert!(app.is_stop_at(head));
let cl = app.comment_at(head).unwrap().clone();
let focused = app.comment_line_to_line(&cl, true, 40);
let unfocused = app.comment_line_to_line(&cl, false, 40);
let border_fg = |line: &ratatui::text::Line| {
line.spans
.iter()
.find(|s| s.content.chars().any(|c| "╭╮╰╯│".contains(c)))
.and_then(|s| s.style.fg)
};
assert_eq!(border_fg(&focused), Some(theme().border_focus));
assert_eq!(border_fg(&unfocused), Some(theme().muted));
}
#[test]
fn split_view_wraps_comment_body_into_the_half_column() {
let cs = parse_report(DIFF).0;
let mut store = CommentStore::default();
store.add_thread(
"f.rs".into(),
Side::New,
LineRange { start: 2, end: 2 },
Some("you".into()),
"The labor market has shifted into a higher gear, powering through \
an energy shock and immigration restrictions to pull more people."
.into(),
);
let mut app = App::with_comments(cs, store);
app.view = View::Split;
app.wrap = false;
let inner: u16 = 90;
app.sync_comment_wrap(inner);
let side_w = (inner as usize).saturating_sub(1 + str_width(SPLIT_DIVIDER)) / 2;
let inner_w = side_w - 2; let indent = 3;
let mut body_rows = 0;
for i in 0..app.split_rows.len() {
if let SplitRowKind::Comment { line, .. } = &app.split_rows[i].kind {
if let CommentKind::Body(b) = &line.kind {
body_rows += 1;
assert!(
str_width(b) + indent <= inner_w,
"body fragment {:?} ({}+{}) exceeds split inner width {}",
b,
str_width(b),
indent,
inner_w
);
}
}
}
assert!(body_rows >= 2, "long body should wrap to several rows");
}
#[test]
fn focusing_a_comment_selects_the_whole_message_and_its_thread() {
let (mut app, tid, reply_id) = app_with_thread(3);
let head = comment_head(&app, &reply_id);
app.selected = head;
assert_eq!(app.focused_thread_id(), Some(tid.clone()));
assert_eq!(app.focused_comment(), Some((tid, reply_id)));
let (lo, hi) = app.comment_unit_span(head).unwrap();
assert!(hi >= lo);
for i in lo..=hi {
assert!(
app.in_selection(i),
"row {i} of the message should highlight"
);
}
assert!(!app.in_selection(lo.saturating_sub(1)) || lo == 0);
assert!(!app.in_selection(hi + 1));
}
#[test]
fn comment_selection_survives_view_toggle() {
let (mut app, tid, reply_id) = app_with_thread(3);
app.selected = comment_head(&app, &reply_id);
app.toggle_view(); assert_eq!(
app.focused_comment(),
Some((tid, reply_id)),
"the same comment should stay focused across a view switch"
);
}