use super::*;
#[test]
fn keys_and_yank() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let press = |app: &mut App, s: &str| s.chars().for_each(|c| app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
press(&mut app, "]g"); assert_eq!(app.rows[app.head.row].line.kind, Kind::Del);
press(&mut app, "jww");
assert_eq!(app.selection_text(false), "new_call");
press(&mut app, "x");
assert_eq!(app.selection_text(false), " new_call(x);\n");
press(&mut app, "kx");
assert_eq!(app.selection_text(true), "- old();\n");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
press(&mut app, "/call");
app.key(enter);
assert_eq!((app.anchor, app.head), (Pos { row: 4, col: 8 }, Pos { row: 4, col: 11 }));
press(&mut app, "N"); assert_eq!(app.head, Pos { row: 4, col: 11 });
press(&mut app, "/CALL");
app.key(enter);
assert!(app.msg.starts_with("no match")); assert_eq!(base64(b"hello"), "aGVsbG8=");
let h = app.hover_lines("```rust\nfn x()\n```\n\ndocs");
assert_eq!(h.len(), 3); assert_eq!(h[0].spans[1].style.fg, Some(Color::Red)); let back = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
app.key(back);
assert_eq!(app.rows.len(), 3); app.key(back);
assert!(app.rows.len() > 3);
app.alphabet = vec!['a', 's'];
app.height = 20;
app.goto(4, 0);
press(&mut app, "gw"); assert_eq!(app.labels[0].2, ['a', 'a']);
press(&mut app, "aa");
assert_eq!(app.selection_text(false), "new_call");
}
#[test]
fn suggestion_block() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.goto(4, 0);
app.select = true;
app.goto(5, 0);
let (a, prefill) = app.suggestion().unwrap();
assert_eq!((a.path.as_str(), a.line, a.left, a.start), ("src/a.rs", 3, false, Some((2, false))));
assert_eq!(prefill, "\n```suggestion\n new_call(x);\n}\n```\n");
app.goto(3, 0);
assert!(app.suggestion().err().unwrap().to_string().contains("removed lines"));
app.rows[4].line.text = "let s = \"```\";".into();
app.select = false;
app.goto(4, 0);
assert!(app.suggestion().unwrap().1.contains("````suggestion\n"));
}
#[test]
fn viewed_files() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.pr = Some(gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "abc".into(), url: String::new() });
let viewed = |p: &str, s: &str| HashMap::from([(p.to_owned(), s.to_owned())]);
app.apply_viewed(viewed("src/a.rs", "VIEWED"));
app.build();
assert_eq!(app.viewed_tag(0).unwrap().0, "✓ Viewed ");
let header = app.rows.iter().find(|r| r.line.kind == Kind::File && r.file == 0).unwrap();
assert!(!header.line.text.contains("Viewed")); assert!(app.collapsed.contains("src/a.rs")); app.collapsed.clear(); app.apply_viewed(viewed("src/a.rs", "VIEWED"));
assert!(app.collapsed.is_empty()); app.apply_viewed(viewed("src/a.rs", "DISMISSED"));
app.build();
assert_eq!(app.viewed_tag(0).unwrap().0, "↻ changed since viewed ");
let h = app.rows.iter().position(|r| r.line.kind == Kind::File && r.file == 0).unwrap();
app.goto(h, 0);
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
assert!(matches!(app.job, Some(Job::Viewed(ref p, true)) if p == "src/a.rs")); }
#[test]
fn help_search() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let k = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE));
let text = |app: &App| -> Vec<String> {
app.popup.as_ref().unwrap().1.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()).collect()
};
k(&mut app, KeyCode::Char('?'));
let all = text(&app).len();
k(&mut app, KeyCode::Char('/'));
"bracket".chars().for_each(|c| k(&mut app, KeyCode::Char(c)));
assert_eq!(text(&app), [" MOVE", " mm matching bracket"]);
k(&mut app, KeyCode::Enter); k(&mut app, KeyCode::Char('j'));
assert!(app.popup.is_some() && text(&app).len() == 2);
k(&mut app, KeyCode::Char('/'));
k(&mut app, KeyCode::Esc); assert_eq!(text(&app).len(), all);
k(&mut app, KeyCode::Char('/'));
"zzzz".chars().for_each(|c| k(&mut app, KeyCode::Char(c)));
assert!(text(&app)[0].contains("no keys match"));
k(&mut app, KeyCode::Enter);
k(&mut app, KeyCode::Char('x')); assert!(app.popup.is_none() && app.help.is_none());
}
#[test]
fn matching_bracket() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let mm = |app: &mut App| {
app.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
};
app.goto(2, 10); mm(&mut app);
assert_eq!(app.head, Pos { row: 5, col: 0 }); mm(&mut app);
assert_eq!(app.head, Pos { row: 2, col: 10 });
app.goto(4, 12); mm(&mut app);
assert_eq!(app.head, Pos { row: 4, col: 14 });
app.goto(2, 0);
mm(&mut app);
assert!(app.msg.contains("not on a bracket"));
let body: String = (2..=19).map(|i| if i == 3 { "+ changed();\n".to_string() } else { format!(" line{i}();\n") }).collect();
let text = format!("diff --git a/b.rs b/b.rs\n--- a/b.rs\n+++ b/b.rs\n@@ -1,19 +1,20 @@\n fn f() {{\n{body} }}\n");
let mut app = App::with("base".into(), diff::parse(&text));
assert!(!app.rows.iter().any(|r| r.line.text == "}")); let open = app.rows.iter().position(|r| r.line.text == "fn f() {").unwrap();
app.goto(open, 7);
mm(&mut app);
assert_eq!(app.rows[app.head.row].line.text, "}");
assert!(app.msg.contains("expanded"));
}
#[test]
fn split_view() {
let sample = "diff --git a/a.rs b/a.rs\n--- a/a.rs\n+++ b/a.rs\n@@ -1,4 +1,3 @@\n ctx\n-old1\n-old2\n+new1\n end\n";
let mut app = App::with("base".into(), diff::parse(sample));
let ctrl = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL);
app.key(ctrl('w'));
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
assert!(app.split);
assert_eq!(app.drows, [
DRow::Full(0), DRow::Full(1), DRow::Pair(Some(2), Some(2)),
DRow::Pair(Some(3), Some(5)), DRow::Pair(Some(4), None), DRow::Pair(Some(6), Some(6)),
]);
app.goto(2, 0); app.vmove(1);
assert_eq!(app.head.row, 5); app.key(ctrl('h'));
assert_eq!(app.head.row, 3); app.vmove(1);
assert_eq!(app.head.row, 4); app.key(ctrl('l'));
assert_eq!(app.head.row, 4); app.vmove(1);
assert_eq!(app.head.row, 6);
app.goto(5, 0); app.key(ctrl('w'));
app.key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert_eq!(app.head.row, 3); app.key(ctrl('w'));
app.key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert_eq!(app.head.row, 5);
}
#[test]
fn comments_placed_and_anchored() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let c = |id, body: &str| gh::Comment {
id, node_id: format!("C{id}"), can_edit: id == 2, can_delete: id == 2, author: "amy".into(), body: body.into(), created: "2026-10-01".into(), pending: false,
};
let t = |left, line, resolved, comments| Thread {
id: format!("T{line:?}"),
path: "src/a.rs".into(), left, line, start_line: None, original_line: Some(1), resolved, outdated: line.is_none(),
hunk: vec![], comments, claude: None,
};
app.threads = vec![
t(true, Some(2), false, vec![c(1, "why remove?"), c(2, "unused")]), t(false, None, true, vec![c(3, "old note")]), ];
app.build();
let kinds: Vec<_> = app.rows.iter().take(11).map(|r| (r.line.kind, r.thread)).collect();
assert_eq!(kinds, [
(Kind::File, None),
(Kind::Hunk, None),
(Kind::Ctx, None),
(Kind::Thread, Some(1)), (Kind::Del, None),
(Kind::Thread, Some(0)),
(Kind::Comment, Some(0)),
(Kind::Thread, Some(0)),
(Kind::Comment, Some(0)),
(Kind::Comment, Some(0)), (Kind::Add, None),
]);
assert!(app.rows[9].edge);
let parts: Vec<_> = (3..10).map(|r| app.box_part(r)).collect();
assert_eq!(parts, [None, None, Some(BoxPart::Top), Some(BoxPart::Body), Some(BoxPart::Divider), Some(BoxPart::Body), Some(BoxPart::Bottom)]);
app.goto(4, 0);
app.select = true;
app.goto(10, 0);
let a = app.selection_anchor().unwrap();
assert_eq!(app.file_ref().unwrap(), "src/a.rs:2");
let moved = Thread { hunk: vec!["+ new_call(x);".into()], ..t(false, None, false, vec![c(4, "nit")]) };
let lines = &app.files[0].lines;
assert_eq!(anchor_line(&moved, lines).map(|i| lines[i].kind), Some(Kind::Add));
let brace = Thread { hunk: vec![" }".into()], original_line: Some(3), ..t(false, None, false, vec![c(5, "x")]) };
assert_eq!(anchor_line(&brace, lines).map(|i| lines[i].new), Some(Some(3)));
let far = Thread { original_line: Some(500), ..brace };
assert_eq!(anchor_line(&far, lines), None);
app.threads[0].comments[1].pending = true;
app.build();
assert!(app.rows.iter().any(|r| r.line.text.starts_with("[pending] amy")));
app.pr = Some(gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "abc".into(), url: "https://github.com/o/r/pull/1".into() });
let tr = app.rows.iter().position(|r| r.thread == Some(0)).unwrap();
app.goto(tr, 0);
let press = |app: &mut App, c| app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
press(&mut app, 'c'); assert!(app.action.is_none() && app.msg.starts_with("r replies"));
press(&mut app, 'r');
assert!(matches!(app.action.take(), Some(Action::Reply { review: true, ref thread, .. }) if thread == "TSome(2)"));
press(&mut app, 'C'); assert!(matches!(app.action.take(), Some(Action::Reply { review: false, .. })));
let second = app.rows.iter().position(|r| r.line.text == "unused").unwrap();
app.goto(second, 0);
press(&mut app, 'r');
assert!(matches!(app.action.take(), Some(Action::Reply { ref quote, .. }) if quote == "> unused\n\n"));
press(&mut app, ' ');
press(&mut app, 'e');
assert!(matches!(app.action.take(), Some(Action::Edit { ref comment, ref body, .. }) if comment == "C2" && body == "unused"));
let first = app.rows.iter().position(|r| r.line.text == "why remove?").unwrap();
app.goto(first, 0);
press(&mut app, ' ');
press(&mut app, 'e');
assert!(app.action.is_none() && app.msg.contains("can't edit"));
app.threads[0].comments[1].body = "> why remove?\n\nunused".into();
app.build();
press(&mut app, ' ');
press(&mut app, 'p');
let p = app.picker.take().unwrap();
assert_eq!(p.items.len(), 1);
assert_eq!((p.items[0].label.as_str(), p.items[0].detail.as_slice()), ("src/a.rs:2 reply", &["unused".to_owned()][..])); let Target::Comment(ti, ci) = p.items[0].target else { panic!() };
app.picker = Some(p);
app.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
let confirm = app.picker.take().unwrap();
assert_eq!((confirm.title, confirm.sel), (" delete comment? ", 0));
assert!(matches!(confirm.items[1].target, Target::DeleteComment(ref id) if id == "C2"));
app.goto_comment(ti, ci);
assert!(app.rows[app.head.row].line.text.starts_with("[pending] amy"));
app.threads[0].comments[1].body = "unused".into();
app.build();
app.goto(first, 0);
press(&mut app, ' ');
press(&mut app, 't');
let items = app.picker.take().unwrap().items;
assert_eq!(items[0].label, "src/a.rs:2 1 reply · [pending] @amy");
assert_eq!(items[0].detail, ["why remove?"]); assert!(items[1].label.contains("[resolved] [outdated] @amy") && items[1].detail == ["old note"]);
press(&mut app, ' ');
press(&mut app, 'd');
assert!(app.msg.contains("can't delete"));
let second = app.rows.iter().position(|r| r.line.text == "unused").unwrap();
app.goto(second, 0);
press(&mut app, ' ');
press(&mut app, 'd');
let p = app.picker.as_ref().unwrap();
assert!(matches!(p.items[0].target, Target::Nothing) && matches!(p.items[1].target, Target::DeleteComment(ref id) if id == "C2"));
app.picker = None;
assert_eq!(clean_body("> unused\n\n<!-- dv: x -->\n", "<!-- dv:", "> unused\n\n"), ""); assert_eq!(clean_body("> unused\n\nagreed\n<!-- dv: x -->", "<!-- dv:", "> unused\n\n"), "> unused\n\nagreed");
let tr = app.rows.iter().position(|r| r.thread == Some(0)).unwrap(); app.goto(tr, 0);
press(&mut app, 'C');
assert!(matches!(app.action.take(), Some(Action::Reply { review: false, .. })));
press(&mut app, 'S');
assert_eq!(app.picker.take().unwrap().items.len(), 3); app.review = Some("R".into());
press(&mut app, 'S');
assert_eq!(app.picker.as_ref().unwrap().items[3].label, "Discard pending review (1 comment(s))");
assert_eq!((a.line, a.left, a.start), (2, false, Some((2, true))));
assert_eq!(wrap("aaa bbb ccc", 7), ["aaa bbb", "ccc"]);
}
#[test]
fn quote_into_conversation() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.pr = Some(gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "abc".into(), url: String::new() });
let line = |heading, md, text: &str| gh::InfoLine { heading, md, text: text.into(), marks: vec![], url: String::new() };
app.info = vec![line(true, false, "conversation (1)"), line(true, false, "@bo · 2d ago"), line(false, true, "Looks good\nbut fix CI")];
app.build();
let quoted = |app: &mut App, row: usize| {
app.goto(row, 0);
app.key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
match app.action.take() {
Some(Action::Converse { quote }) => quote,
_ => panic!("no conversation comment started"),
}
};
let body = app.rows.iter().position(|r| r.line.text == "but fix CI").unwrap();
assert_eq!(quoted(&mut app, body), "> Looks good\n> but fix CI\n\n");
let author = app.rows.iter().position(|r| r.line.text == "@bo · 2d ago").unwrap();
assert_eq!(quoted(&mut app, author), "> Looks good\n> but fix CI\n\n"); let heading = app.rows.iter().position(|r| r.line.text == "conversation (1)").unwrap();
assert_eq!(quoted(&mut app, heading), ""); app.goto(body, 0);
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.action.is_none());
assert_eq!(app.msg, "select code lines to comment on");
let keep = app.cursor_key();
app.rebuild(keep);
assert_eq!(app.head.row, body);
app.info = vec![line(true, false, "description"), line(false, true, "")];
app.build();
let ph = app.rows.iter().position(|r| r.line.text == "(no description)").unwrap();
assert_eq!(quoted(&mut app, ph), "");
let code = app.rows.iter().position(|r| r.line.kind == Kind::Add).unwrap();
app.goto(code, 0);
app.key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert!(!matches!(app.action, Some(Action::Converse { .. })));
}
#[test]
fn claude_review() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let p = std::env::temp_dir().join(format!("dv-claude-view-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "summary": "Looks fine.", "comments": [
{ "path": "src/a.rs", "line": 2, "body": "**nit:** name" },
{ "path": "src/a.rs", "line": 2, "side": "old", "body": "why remove?" } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_threads();
app.build();
let texts: Vec<&str> = app.rows.iter().map(|r| r.line.text.as_str()).collect();
assert_eq!(&texts[..3], ["Claude's review", "claude's review", "Looks fine."]); assert_eq!((app.box_part(1), app.box_part(2), app.box_part(3)), (Some(BoxPart::Top), Some(BoxPart::Body), Some(BoxPart::Bottom)));
let row = |app: &App, side_left: bool| {
app.rows.iter().position(|r| r.line.kind == Kind::Thread && r.thread.is_some_and(|t| app.threads[t].left == side_left)).unwrap()
};
assert_eq!(app.rows[row(&app, true) - 1].line.kind, Kind::Del);
assert_eq!(app.rows[row(&app, false) - 1].line.kind, Kind::Add);
let press = |app: &mut App, s: &str| s.chars().for_each(|c| app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
app.goto(row(&app, false), 0);
press(&mut app, "r");
assert!(matches!(app.action.take(), Some(Action::ClaudeReply(k)) if k == "c1")); press(&mut app, " r");
assert!(std::fs::read_to_string(&p).unwrap().contains("\"resolved\": true"));
let t = app.rows[app.head.row].thread.unwrap();
assert!(app.threads[t].resolved && !app.thread_open(t));
app.goto(row(&app, true), 0);
press(&mut app, " e");
assert!(app.action.is_none() && app.msg.contains("r replies to Claude"));
press(&mut app, " d");
assert_eq!(app.threads.iter().filter(|t| t.claude.is_some()).count(), 1);
std::fs::write(&p, r#"{ "comments": [] }"#).unwrap();
app.claude_mtime = None;
app.watch_claude();
assert!(app.threads.is_empty() && app.msg.contains("0 comment(s)"));
_ = std::fs::remove_file(&p);
}
#[test]
fn claude_outdated() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let p = std::env::temp_dir().join(format!("dv-claude-outdated-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 1, "body": "moved", "code": [" new_call(x);"] },
{ "path": "src/a.rs", "line": 2, "body": "gone", "code": [" other();"] } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_threads();
app.build();
_ = std::fs::remove_file(&p);
let (moved, gone) = (&app.threads[0], &app.threads[1]);
assert_eq!((moved.line, moved.outdated), (Some(2), false));
assert!(gone.outdated && !app.thread_open(1)); let head = app.rows.iter().position(|r| r.thread == Some(1)).unwrap();
assert!(app.rows[head].line.text.starts_with("[outdated] claude"));
let m = app.rows.iter().position(|r| r.thread == Some(0)).unwrap();
assert_eq!(app.rows[m - 1].line.new, Some(2));
app.goto(head, 0);
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.action.is_none() && app.msg.contains("outdated"));
}
#[test]
fn claude_menu() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let p = std::env::temp_dir().join(format!("dv-claude-menu-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 2, "body": "a", "replies": [ { "author": "user", "body": "why?" } ] },
{ "path": "src/a.rs", "line": 2, "body": "b", "resolved": true, "replies": [ { "author": "user", "body": "ok" } ] },
{ "path": "src/a.rs", "line": 2, "body": "**nit:** use `x` ([docs](https://x.y))", "replies": [ { "author": "claude", "body": "done" } ] } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_threads();
_ = std::fs::remove_file(&p);
assert_eq!(app.waiting_replies(), 1); app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let p = app.picker.as_ref().unwrap();
assert!(p.items[0].label.starts_with("Update Claude's review"));
assert!(p.items[1].label.contains("(1 waiting)") && p.sel == 1); assert!(p.items[2].label.contains("make the changes (2 open comments")); app.picker = None;
app.pick_claude(agent::Ask::Fix);
assert!(matches!(app.action.take(), Some(Action::ClaudeWith(agent::Ask::Fix))));
app.pick_claude(agent::Ask::Review); assert!(matches!(app.action.take(), Some(Action::ClaudeWith(agent::Ask::Review))));
assert!(app.claude_prompt(agent::Ask::Fix, "keep the API").ends_with("The user adds:\nkeep the API"));
assert!(!app.claude_prompt(agent::Ask::Fix, " ").contains("The user adds"));
app.picker = None;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
let labels: Vec<&str> = app.picker.as_ref().unwrap().items.iter().map(|i| i.label.as_str()).collect();
assert_eq!(labels.len(), 3);
assert!(labels[0].contains("[waiting]") && !labels[1].contains("[waiting]") && !labels[2].contains("[waiting]"));
assert_eq!(labels[0], "src/a.rs:2 1 reply · [waiting] @claude");
let p = app.picker.as_ref().unwrap();
assert_eq!(p.items[2].detail, ["nit: use x (docs)"]); app.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert_eq!(app.matches(), [2]);
app.picker = None;
app.threads.clear();
app.run_claude(agent::Ask::Answer, "");
assert!(app.msg.contains("no replies waiting") && app.claude_job.is_none());
}
#[test]
fn posting_shows_progress() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let msg = app.post_later(Post::Converse("hi".into())).unwrap();
assert_eq!(msg, "posting…");
assert!(matches!(app.job, Some(Job::Post(Post::Converse(_)))));
let job = app.job.take().unwrap();
app.run_job(job);
assert!(app.msg.contains("no PR"));
}
#[test]
fn help_two_columns() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let rows = |app: &mut App, w: u16| {
app.width = w;
app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let lines = app.popup.take().unwrap().1;
app.help = None;
lines
};
let one = rows(&mut app, 100);
let two = rows(&mut app, 200);
assert!(two.len() <= one.len() / 2 + 4, "{} vs {}", two.len(), one.len()); let text: String = two[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.starts_with(" MOVE") && text.trim_end().ends_with(text.split_whitespace().last().unwrap())); assert!(two.iter().all(|l| l.width() <= 197));
}
#[test]
fn copy_refs() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let url = "https://github.com/o/r/pull/1";
app.pr = Some(gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "abc".into(), url: url.into() });
let c = |id| gh::Comment { id, node_id: String::new(), can_edit: false, can_delete: false, author: "amy".into(), body: "hi".into(), created: String::new(), pending: false };
app.threads = vec![gh::Thread {
id: "T".into(), path: "src/a.rs".into(), left: false, line: Some(2), start_line: None, original_line: Some(2),
resolved: false, outdated: false, hunk: vec![], comments: vec![c(11), c(12)], claude: None,
}];
let heading = |text: &str, url: &str| gh::InfoLine { heading: true, md: false, text: text.into(), marks: vec![], url: url.into() };
let body = |text: &str| gh::InfoLine { heading: false, md: true, text: text.into(), marks: vec![], url: String::new() };
app.info = vec![heading("@bo · 1d ago", "https://github.com/o/r/pull/1#issuecomment-5"), body("Looks good")];
app.build();
let at = |app: &mut App, text: &str| {
let r = app.rows.iter().position(|r| r.line.text == text).unwrap();
app.goto(r, 0);
app.cursor_ref().unwrap()
};
assert_eq!(at(&mut app, "Looks good"), "https://github.com/o/r/pull/1#issuecomment-5"); assert_eq!(at(&mut app, "PR #1 · t"), url); let second = app.rows.iter().enumerate().filter(|(_, r)| r.thread == Some(0) && r.line.text == "hi").nth(1).unwrap().0;
app.goto(second, 0);
assert_eq!(app.cursor_ref().unwrap(), "https://github.com/o/r/pull/1#discussion_r12"); assert_eq!(app.context_keys().unwrap().0, "review thread"); assert_eq!(app.context_keys().unwrap().1[0], ("r", "reply (review)"));
app.threads[0].claude = Some("c1".into());
app.threads[0].comments[0].author = crate::claude::AUTHOR.into();
app.threads[0].start_line = Some(1);
app.build();
app.goto(app.rows.iter().position(|r| r.thread == Some(0)).unwrap(), 0);
assert_eq!(app.cursor_ref().unwrap(), "src/a.rs:1-2"); assert_eq!(app.context_keys().unwrap().0, "Claude's comment");
let code = app.rows.iter().position(|r| r.line.kind == Kind::Add).unwrap();
app.goto(code, 0);
assert_eq!(app.cursor_ref().unwrap(), "src/a.rs:2");
assert!(app.context_keys().is_none()); }
#[test]
fn cycle_claude_comments() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let p = std::env::temp_dir().join(format!("dv-claude-cycle-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 3, "body": "second" },
{ "path": "src/a.rs", "line": 1, "body": "first" } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_threads();
_ = std::fs::remove_file(&p);
app.collapsed.insert("src/a.rs".into()); app.build();
let press = |app: &mut App, s: &str| s.chars().for_each(|c| app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
let on = |app: &App| app.rows[app.head.row].thread.map(|t| app.threads[t].comments[0].body.clone());
press(&mut app, "]a");
assert_eq!(on(&app).as_deref(), Some("first")); press(&mut app, "]a");
assert_eq!(on(&app).as_deref(), Some("second"));
press(&mut app, "]a");
assert_eq!(on(&app).as_deref(), Some("first")); press(&mut app, "[a");
assert_eq!(on(&app).as_deref(), Some("second")); let l3 = app.rows.iter().position(|r| r.line.new == Some(3) && r.thread.is_none()).unwrap();
app.goto(l3, 0);
press(&mut app, "]a");
assert_eq!(on(&app).as_deref(), Some("second"));
}
#[test]
fn replies_while_claude_works_wait_for_it() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let child = std::process::Command::new("sleep").arg("5").spawn().unwrap();
let log = std::env::temp_dir().join("dv-test-none.log");
app.claude_job = Some(agent::ClaudeJob { child, log, label: "answering", forked: false });
let msg = app.answer_now();
assert!(app.claude_again && msg.contains("when it's done"));
if let Some(mut job) = app.claude_job.take() {
_ = job.child.kill();
}
}
#[test]
fn claude_session_per_pr() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let dir = std::env::temp_dir().join(format!("dv-session-{}", std::process::id()));
let review = dir.join("pr-7.json");
app.claude_path = Some(review.clone());
app.set_session(Some("11111111-2222-4333-8444-555555555555".into()));
let saved = std::fs::read_to_string(crate::claude::session_path(&review)).unwrap();
assert_eq!(saved, format!("{}\t11111111-2222-4333-8444-555555555555\n", app.root.display()));
let notes = crate::claude::notes_path().unwrap();
assert_eq!(app.claude_dirs(), [std::ffi::OsString::from("--add-dir"), notes.parent().unwrap().as_os_str().to_owned()]);
app.set_session(None);
assert!(!crate::claude::session_path(&review).exists());
_ = std::fs::remove_dir_all(&dir);
}
#[test]
fn approve_says_ack() {
let pr = gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "cb1cec09".into(), url: String::new() };
assert_eq!(ack("APPROVE", Some(&pr), "cb1cec09"), "ACK cb1cec09");
let diverged = ack("APPROVE", Some(&pr), "698eec92");
assert!(diverged.starts_with("<!-- dv: your checkout is at 698eec92") && diverged.ends_with("\nACK cb1cec09"));
assert_eq!(clean_body(&diverged, HINT, ""), "ACK cb1cec09");
assert_eq!(ack("COMMENT", Some(&pr), "cb1cec09"), "");
assert_eq!(ack("APPROVE", None, "cb1cec09"), "");
}
#[test]
fn ask_claude_on_lines() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let dir = std::env::temp_dir().join(format!("dv-ask-{}", std::process::id()));
app.claude_path = Some(dir.join("pr-1.json"));
let add = app.rows.iter().position(|r| r.line.kind == Kind::Add).unwrap();
app.goto(add, 0);
app.key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let Some(Action::AskClaude { anchor, code }) = app.action.take() else { panic!("no comment for Claude started") };
assert_eq!((anchor.path.as_str(), anchor.line, anchor.left), ("src/a.rs", 2, false));
assert_eq!(code, [" new_call(x);"]);
crate::claude::add_comment(app.claude_path.as_ref().unwrap(), &anchor, code, "why?").unwrap();
app.load_claude();
_ = std::fs::remove_dir_all(&dir);
assert_eq!(app.waiting_replies(), 1);
}
#[test]
fn cycle_pending_comments() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let c = |id, pending| gh::Comment { id, node_id: String::new(), can_edit: true, can_delete: true, author: "me".into(), body: format!("c{id}"), created: String::new(), pending };
let t = |line, comments| gh::Thread {
id: String::new(), path: "src/a.rs".into(), left: false, line: Some(line), start_line: None, original_line: Some(line),
resolved: false, outdated: false, hunk: vec![], comments, claude: None,
};
app.threads = vec![t(3, vec![c(1, false), c(2, true)]), t(1, vec![c(3, true)]), t(2, vec![c(4, false)])];
app.build();
let press = |app: &mut App, s: &str| s.chars().for_each(|ch| app.key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)));
let on = |app: &App| app.rows[app.head.row + 1].line.text.clone(); press(&mut app, "]p");
assert_eq!(on(&app), "c3"); press(&mut app, "]p");
assert_eq!(on(&app), "c2"); press(&mut app, "]p");
assert_eq!(on(&app), "c3"); press(&mut app, "[p");
assert_eq!(on(&app), "c2");
}
#[test]
fn your_comments_for_claude_stay_private() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let dir = std::env::temp_dir().join(format!("dv-private-{}", std::process::id()));
let p = dir.join("pr-1.json");
let a = gh::Anchor { path: "src/a.rs".into(), line: 2, left: false, start: None };
crate::claude::add_comment(&p, &a, vec![" new_call(x);".into()], "is this right?").unwrap();
app.claude_path = Some(p);
app.pr = Some(gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "abc".into(), url: String::new() });
app.load_threads();
app.build();
_ = std::fs::remove_dir_all(&dir);
app.goto(app.rows.iter().position(|r| r.thread == Some(0)).unwrap(), 0);
assert_eq!(app.context_keys().unwrap().0, "your comment for Claude");
app.key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert!(app.action.is_none() && app.msg.contains("private"));
assert!(!agent::claude_wrote(&app.threads[0]));
assert_eq!(app.change_requests(), 1); let del = app.rows.iter().position(|r| r.line.kind == Kind::Del).unwrap();
let add = app.rows.iter().position(|r| r.line.kind == Kind::Add).unwrap();
app.goto(del, 0);
app.select = true;
app.goto(add, 0);
app.key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(app.action.is_none() && app.msg.contains("one side"));
}
#[test]
fn dismiss_outdated_claude_comments() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let dir = std::env::temp_dir().join(format!("dv-outdated-{}", std::process::id()));
let p = dir.join("pr-1.json");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&p, r#"{ "comments": [
{ "id": "c1", "path": "src/a.rs", "line": 2, "body": "gone 1", "code": [" nope();"] },
{ "id": "c2", "path": "src/a.rs", "line": 2, "body": "current", "code": [" new_call(x);"] },
{ "id": "c3", "path": "src/a.rs", "line": 1, "body": "gone 2", "code": ["fn nope() {"] },
{ "id": "c4", "path": "src/a.rs", "line": 1, "author": "user", "body": "mine", "code": ["fn other() {"] } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_threads();
app.build();
assert_eq!(app.outdated_claude_comments(), ["c1", "c3"]);
app.dismiss_outdated(false);
assert_eq!(app.picker.take().unwrap().sel, 0); app.dismiss_outdated(true);
let left: Vec<_> = app.threads.iter().filter_map(|t| t.claude.clone()).collect();
_ = std::fs::remove_dir_all(&dir);
assert_eq!(left, ["c2", "c4"]); }
#[test]
fn comments_in_the_margin() {
let sample = "diff --git a/a.rs b/a.rs\nindex 1..2 100644\n--- a/a.rs\n+++ b/a.rs\n@@ -1,4 +1,4 @@\n one\n two\n three\n-four\n+four!\n";
let mut app = App::with("base".into(), diff::parse(sample));
let c = |id, body: &str| gh::Comment { id, node_id: String::new(), can_edit: false, can_delete: false, author: "amy".into(), body: body.into(), created: String::new(), pending: false };
let t = |line, comments| gh::Thread {
id: String::new(), path: "a.rs".into(), left: false, line: Some(line), start_line: None, original_line: Some(line),
resolved: false, outdated: false, hunk: vec![], comments, claude: None,
};
app.threads = vec![t(1, vec![c(1, "first")]), t(2, vec![c(2, "second")])];
app.margin_active = true;
app.build();
let code = |d: usize| match app.drows[d] { DRow::Margin(c, _) => c, _ => None };
let note = |d: usize| match app.drows[d] { DRow::Margin(_, n) => n, _ => None };
assert_eq!(app.drows[0], DRow::Full(0)); assert_eq!((1..7).map(code).collect::<Vec<_>>(), [Some(1), Some(2), Some(6), Some(10), Some(11), Some(12)]);
assert_eq!((2..5).map(note).collect::<Vec<_>>(), [Some(3), Some(4), Some(5)]);
assert_eq!((5..7).map(note).collect::<Vec<_>>(), [Some(7), Some(8)]);
assert_eq!(app.drows.len(), 8);
assert_eq!((code(7), note(7)), (None, Some(9)));
app.goto(2, 0); let ctrl_w = |app: &mut App, c| {
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
};
ctrl_w(&mut app, 'l');
assert_eq!(app.head.row, 3); assert_eq!(app.lit_code(), Some(2)); ctrl_w(&mut app, 'h');
assert_eq!(app.head.row, 2);
assert_eq!(app.lit_thread(), Some(0)); app.goto(12, 0); app.vmove(1); assert_eq!(app.head.row, 12);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
app.goto(0, 0);
"/first".chars().for_each(|ch| app.key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)));
app.key(enter);
assert_eq!(app.head.row, 4); app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.head.row, 5); }
#[test]
fn conversation_beside_the_description() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.pr = Some(gh::Pr { number: 1, node_id: "PR".into(), title: "t".into(), base: "main".into(), head_oid: "abc".into(), url: String::new() });
let heading = |t: &str| gh::InfoLine { heading: true, md: false, text: t.into(), marks: vec![], url: String::new() };
let md = |t: &str| gh::InfoLine { heading: false, md: true, text: t.into(), marks: vec![], url: String::new() };
app.info = vec![
heading("description"), md("line 1\nline 2\nline 3\nline 4\nline 5"),
heading("conversation (3)"),
heading("@bo · 2d ago"), md("short"),
heading("@cy · 1d ago"), md("also short"),
heading("@dee · 1h ago"), md("late"),
];
app.margin_active = true;
app.build();
let by_text = |t: &str| app.rows.iter().position(|r| r.line.text == t).unwrap();
assert!(app.rows[by_text("line 3")].lead);
assert!(app.rows[by_text("short")].aside && app.rows[by_text("also short")].aside);
let d = app.d_of[by_text("conversation (3)")];
assert_eq!(d, app.d_of[by_text("description")]); assert!(matches!(app.drows[d], DRow::Margin(Some(_), Some(_))));
let late = by_text("late");
assert!(!app.rows[late].aside && matches!(app.drows[app.d_of[late]], DRow::Full(_)));
}
#[test]
fn margin_tags_on_their_own_line() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let c = gh::Comment { id: 1, node_id: String::new(), can_edit: false, can_delete: false, author: "amy".into(), body: "old".into(), created: String::new(), pending: false };
app.threads = vec![gh::Thread {
id: String::new(), path: "src/a.rs".into(), left: false, line: Some(2), start_line: None, original_line: Some(2),
resolved: true, outdated: false, hunk: vec![], comments: vec![c], claude: None,
}];
let rows = |app: &App| app.rows.iter().filter(|r| r.thread == Some(0)).map(|r| r.line.text.clone()).collect::<Vec<_>>();
app.build();
assert!(rows(&app)[0].starts_with("[resolved] amy")); app.margin_active = true;
app.build();
let r = rows(&app);
assert!(r[0].starts_with("amy") && r[1] == "[resolved]", "{r:?}"); }
#[test]
fn what_claude_was_refused() {
use agent::refusal;
let v = |d: serde_json::Value| serde_json::json!({ "permission_denials": d });
assert_eq!(refusal(&v(serde_json::json!([]))), None);
let edit = serde_json::json!({ "tool_name": "Edit", "tool_input": { "file_path": "/r/notes.md" } });
assert_eq!(refusal(&v(serde_json::json!([edit]))).unwrap(), "Edit /r/notes.md");
let bash = serde_json::json!({ "tool_name": "Bash", "tool_input": { "command": format!("cat <<EOF > x\n{}\nEOF", "y".repeat(200)) } });
let grep = serde_json::json!({ "tool_name": "Grep", "tool_input": { "pattern": "fn main", "path": "/r/src" } });
assert_eq!(refusal(&v(serde_json::json!([bash, grep]))).unwrap(), "Bash cat <<EOF > x (and 1 more)");
assert_eq!(refusal(&v(serde_json::json!([grep]))).unwrap(), "Grep /r/src"); }
#[test]
fn mouse() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.build();
app.height = 20;
app.geom = mouse::Geom { relw: 0, gw: 10, ghw: 6, half: 40, ctw: 0, picker: None };
let at = |kind, x: u16, y: u16| MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE };
app.mouse(at(MouseEventKind::Down(MouseButton::Left), 10 + 4, 4)); assert_eq!(app.head, Pos { row: 4, col: 4 });
app.mouse(at(MouseEventKind::Drag(MouseButton::Left), 10 + 11, 4));
assert_eq!(app.selection_text(false), "new_call");
app.mouse(at(MouseEventKind::Down(MouseButton::Left), 1, 0));
assert!(app.collapsed.contains("src/a.rs"));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
app.mouse(at(MouseEventKind::ScrollDown, 30, 5));
assert_eq!(app.picker.as_ref().unwrap().sel, 1);
app.geom.picker = Some(mouse::PickerGeom { list: Rect { x: 10, y: 4, width: 60, height: 5 }, item_h: 1, off: 0 });
app.mouse(at(MouseEventKind::Down(MouseButton::Left), 20, 6)); assert_eq!(app.picker.as_ref().unwrap().sel, 2);
app.mouse(at(MouseEventKind::Down(MouseButton::Left), 20, 6)); assert!(app.picker.is_none() && app.rows[app.head.row].line.text.starts_with("img.png"));
}
#[test]
fn make_changes_without_comments() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.claude_path = Some(std::env::temp_dir().join(format!("dv-fix-{}", std::process::id())).join("pr-1.json"));
app.pick_claude(agent::Ask::Fix);
assert!(matches!(app.action.take(), Some(Action::ClaudeWith(agent::Ask::Fix))));
assert!(!app.run_claude(agent::Ask::Fix, " "));
assert!(app.msg.starts_with("nothing to change") && app.claude_job.is_none());
let child = std::process::Command::new("sleep").arg("5").spawn().unwrap();
app.claude_job = Some(agent::ClaudeJob { child, log: std::env::temp_dir().join("dv-test-busy.log"), label: "reviewing", forked: false });
app.pick_claude(agent::Ask::Fix);
assert!(app.action.is_none() && app.msg.contains("still working"));
if let Some(mut job) = app.claude_job.take() {
_ = job.child.kill();
}
let prompt = app.claude_prompt(agent::Ask::Fix, "rename x to y");
assert!(prompt.starts_with("Make the change the user asks for below") && prompt.ends_with("rename x to y"));
}