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.header_tag(0).unwrap().0, "✓ Viewed ");
assert!(app.header_tag(INFO).is_none()); app.pr_state = Some(("DRAFT", "6e7681"));
assert_eq!(app.header_tag(INFO).unwrap().0, " DRAFT ");
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.header_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, severity: String::new(),
};
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');
let labels: Vec<String> = app.picker.take().unwrap().items.into_iter().map(|i| i.label).collect();
assert_eq!(labels[..4], ["Comment", "Approve", "Request changes", "Comment and close the PR"]); assert_eq!(labels[4], " ! 1 unresolved thread(s) on GitHub");
assert!(labels[5].starts_with(" ! your checkout isn't the PR's head") && labels.len() == 6);
app.review = Some("R".into());
press(&mut app, 'S');
assert_eq!(app.picker.as_ref().unwrap().items[4].label, "Discard pending review (1 comment(s))");
app.picker = None;
press(&mut app, 'C'); assert!(app.action.is_none() && app.msg.starts_with("you have a pending review (1)"));
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_claude(); 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_claude(); 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_claude(); _ = 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())); let heading = app.ui.heading.add_modifier(ratatui::style::Modifier::BOLD);
assert_eq!(two[0].style, Style::default());
let styled: Vec<&str> = two[0].spans.iter().filter(|s| s.style == heading).map(|s| s.content.as_ref()).collect();
assert_eq!(styled.len(), 2, "{:?}", two[0]);
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, severity: String::new(),
}];
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_claude(); _ = 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!(saved.starts_with(&format!("{}\t11111111-2222-4333-8444-555555555555\t", app.root.display()))); let notes = crate::claude::notes_path().unwrap();
assert_eq!(agent::claude_dirs(), [std::ffi::OsString::from("--add-dir"), notes.parent().unwrap().as_os_str().to_owned()]);
app.claude_menu();
let items = app.picker.take().unwrap().items;
assert!(matches!(items.last().unwrap().target, Target::ClaudeFresh));
app.fresh_claude_session();
assert!(app.claude_session.is_none() && app.msg.starts_with("the next Claude run starts a new session"));
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, severity: String::new(),
};
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_claude(); 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_claude(); 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, severity: String::new(),
};
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, 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, severity: String::new(),
}];
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"));
}
#[test]
fn whose_turn_and_severity() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let p = std::env::temp_dir().join(format!("dv-claude-turn-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 2, "body": "a", "severity": "Bug", "replies": [ { "author": "user", "body": "why?" }, { "author": "claude", "body": "because" } ] },
{ "path": "src/a.rs", "line": 2, "body": "b", "replies": [ { "author": "user", "body": "why?" } ] },
{ "path": "src/a.rs", "line": 2, "body": "c" } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_claude();
_ = std::fs::remove_file(&p);
let turns: Vec<_> = app.threads.iter().map(agent::turn).collect();
assert_eq!(turns, [Some("[answered] "), Some("[waiting] "), None]); app.build();
assert!(app.rows.iter().any(|r| r.line.text.starts_with("[bug] [answered] claude")));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
assert_eq!(app.picker.as_ref().unwrap().items[0].label, "src/a.rs:2 2 replies · [bug] [answered] @claude");
assert_eq!(app.submit_notes()[0], "3 open comment(s) in Claude's review (1 answered, your turn)");
app.picker = None;
app.theirs = true;
app.claude_menu();
assert_eq!(app.picker.take().unwrap().items[2].label, "Ask Claude to suggest the changes (3 open comments; their PR: no edits)");
assert!(app.claude_prompt(agent::Ask::Fix, "").contains("don't edit the code"));
assert!(app.claude_prompt(agent::Ask::Answer, "").contains("don't edit the code")); }
#[test]
fn commit_draft_from_claudes_changes() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let p = std::env::temp_dir().join(format!("dv-claude-draft-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 2, "body": "a", "replies": [
{ "author": "user", "body": "rename it", "created": "2026-10-16T12:00:00Z" },
{ "author": "claude", "body": "Changed: renamed `x` to `count`.\nMore detail.", "created": "2026-10-16T12:01:00Z" } ] },
{ "path": "src/a.rs", "line": 2, "body": "b", "replies": [
{ "author": "claude", "body": "Changed: an older change", "created": "2026-10-16T11:00:00Z" } ] } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_claude();
_ = std::fs::remove_file(&p);
let since = gh::epoch_secs("2026-10-16T11:30:00Z").unwrap(); let d = git::draft(&app.threads, since, " M src/a.rs\n?? new.rs\n");
assert_eq!(d, "Address review comments\n\n- renamed `x` to `count`.\n\n<!-- dv: will commit: M src/a.rs -->\n<!-- dv: will commit: ?? new.rs -->\n");
assert_eq!(clean_body(&d, HINT, ""), "Address review comments\n\n- renamed `x` to `count`."); assert!(git::draft(&app.threads, i64::MAX, "").trim().is_empty()); }
#[test]
fn messages_kept_in_full() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
app.msg = "posting…".into(); app.log_msg();
app.msg = "gh api: Validation Failed: user_id can only have one pending review per pull request".into();
app.log_msg();
app.log_msg(); app.msg.clear();
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert_eq!(app.msgs.len(), 1);
let (title, lines, _) = app.popup.take().unwrap();
assert!(title.starts_with(" messages"));
let text: Vec<String> = lines.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect();
let text = text.join(" ").split_whitespace().collect::<Vec<_>>().join(" "); assert!(text.contains("one pending review per pull request"), "{text}");
}
#[test]
fn threads_on_files_gone_from_the_diff() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let c = gh::Comment {
id: 1, node_id: "C1".into(), can_edit: false, can_delete: false, author: "amy".into(), body: "still here?".into(),
created: "2026-10-01".into(), pending: false,
};
app.threads = vec![gh::Thread {
id: "T1".into(), path: "src/reverted.rs".into(), left: false, line: Some(4), start_line: None, original_line: Some(4),
resolved: false, outdated: false, hunk: vec![], comments: vec![c], claude: None, severity: String::new(),
}];
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.build();
let texts: Vec<&str> = app.rows.iter().map(|r| r.line.text.as_str()).collect();
let at = texts.iter().position(|t| *t == "comments on files no longer in the diff (1)").expect("listed");
assert_eq!(texts[at + 1], "src/reverted.rs");
assert!(texts[at + 2].contains("amy") && app.rows[at + 2].thread == Some(0));
app.goto(0, 0);
app.key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert_eq!(app.rows[app.head.row].thread, Some(0));
app.key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert!(matches!(app.action.take(), Some(Action::Reply { review: false, .. })));
app.margin_active = true;
app.build();
let gone_rows = app.rows.iter().filter(|r| r.thread == Some(0)).count();
let placed = app.drows.iter().filter(|d| matches!(d, DRow::Full(r) if app.rows[*r].thread == Some(0))).count();
assert_eq!(placed, gone_rows);
}
#[test]
fn untracked_files_stay_off_github() {
let mut files = diff::parse(diff::SAMPLE);
files[0].meta = "untracked".into();
let mut app = App::with("base".into(), files);
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 a = Anchor { path: "src/a.rs".into(), line: 2, left: false, start: None };
assert!(app.on_github(a).is_err_and(|e| format!("{e:#}").contains("untracked")));
let line = app.rows.iter().position(|r| r.line.kind == Kind::Add).unwrap();
app.goto(line, 0);
app.toggle_viewed();
assert!(app.msg.contains("untracked") && app.job.is_none());
}
#[test]
fn claude_runs_say_what_they_cost() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let log = std::env::temp_dir().join(format!("dv-claude-cost-{}.log", std::process::id()));
std::fs::write(&log, r#"{"is_error": false, "num_turns": 5, "total_cost_usd": 0.2231, "session_id": "s"}"#).unwrap();
let mut child = std::process::Command::new("true").spawn().unwrap();
_ = child.wait();
app.claude_job = Some(agent::ClaudeJob { child, log: log.clone(), label: "answering", forked: true });
app.poll_claude();
_ = std::fs::remove_file(&log);
assert_eq!(app.msg, "Claude is done answering (5 turns, $0.22)");
assert!((app.claude_spent - 0.2231).abs() < 1e-9);
}
#[test]
fn claude_pane_asking() {
assert!(agent::asking(" Do you want to make this edit to ensure.rs?\n ❯ 1. Yes\n 2. No"));
assert!(agent::asking(" Is this a project you created or one you trust?\n ❯ No, exit\n Yes, I trust this folder"));
assert!(!agent::asking("● Done.\n───\n❯ \n───\n ⏵⏵ auto mode on"));
}
#[test]
fn new_until_seen() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let dir = std::env::temp_dir().join(format!("dv-new-{}", std::process::id()));
std::fs::create_dir_all(dir.join("reviews")).unwrap();
let p = dir.join("reviews/pr-1.json");
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 2, "body": "a" },
{ "path": "src/a.rs", "line": 2, "body": "b", "replies": [ { "author": "user", "body": "why?" } ] },
{ "path": "src/a.rs", "line": 2, "author": "user", "body": "mine" } ] }"#).unwrap();
app.claude_path = Some(p.clone());
app.load_claude();
app.load_seen();
assert_eq!(app.seen_path.as_deref(), Some(dir.join("seen/pr-1.json").as_path()));
let new = |app: &App| app.threads.iter().map(|t| app.is_new(t)).collect::<Vec<_>>();
assert_eq!(new(&app), [true, true, false]); app.build();
assert!(app.rows.iter().any(|r| r.line.text.starts_with("[new] claude")));
app.next_new(true);
app.watch_seen();
assert_eq!(new(&app), [true, true, false]); let on = app.head.row;
app.next_new(true);
app.watch_seen();
assert_eq!(new(&app), [false, true, false]);
assert_eq!(app.rows.iter().filter(|r| r.line.text.starts_with("[new]")).count(), 1); assert_eq!(app.rows[app.head.row].thread, Some(1)); assert!(app.head.row > on);
app.seen.clear();
app.load_seen();
assert_eq!(new(&app), [false, true, false]);
app.new_picker();
let items = &app.picker.as_ref().unwrap().items;
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "src/a.rs:2 1 reply · [new] [waiting] @claude");
app.picker = None;
app.watch_seen();
assert_eq!(new(&app), [false, true, false]);
app.seeing_since -= std::time::Duration::from_secs(5);
app.watch_seen();
assert_eq!(new(&app), [false, false, false]);
assert_eq!(app.rows[app.head.row].thread, Some(1)); app.seen.clear();
app.load_seen();
_ = std::fs::remove_dir_all(&dir);
assert_eq!(new(&app), [false, false, false]);
}
#[test]
fn claude_view() {
let body: String = (1..=30).map(|n| format!("+line {n}\n")).collect();
let text = format!(
"diff --git a/big.rs b/big.rs\nnew file mode 100644\n--- /dev/null\n+++ b/big.rs\n@@ -0,0 +1,30 @@\n{body}{}",
diff::SAMPLE
);
let mut app = App::with("base".into(), diff::parse(&text));
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 plain = |text: &str| gh::InfoLine { heading: false, md: false, text: text.into(), marks: vec![], url: String::new() };
let heading = |text: &str| gh::InfoLine { heading: true, md: false, text: text.into(), marks: vec![], url: String::new() };
let md = |text: &str| gh::InfoLine { heading: false, md: true, text: text.into(), marks: vec![], url: String::new() };
app.info = vec![plain("OPEN @bo · main ← x"), plain("review: waiting on @cy"), heading("description"), md("Long story")];
app.claude_summary = "Looks fine.".into();
let c = |author: &str| gh::Comment { id: 1, node_id: String::new(), can_edit: false, can_delete: false, author: author.into(), body: "hm".into(), created: String::new(), pending: false };
let t = |path: &str, line, claude: Option<&str>, author| gh::Thread {
id: String::new(), path: path.into(), left: false, line: Some(line), start_line: None, original_line: Some(line),
resolved: false, outdated: false, hunk: vec![], comments: vec![c(author)], claude: claude.map(Into::into), severity: String::new(),
};
app.threads = vec![t("big.rs", 20, Some("c1"), "claude"), t("big.rs", 5, None, "amy"), t("src/a.rs", 2, None, "amy")];
app.build();
let texts = |app: &App| app.rows.iter().map(|r| r.line.text.clone()).collect::<Vec<_>>();
assert!(texts(&app).contains(&"Long story".to_owned()));
app.toggle_view(View::Claude);
let v = texts(&app);
assert!(v.contains(&"OPEN @bo · main ← x".to_owned()) && v.contains(&"Looks fine.".to_owned()), "{v:?}");
assert!(!v.iter().any(|t| t == "Long story" || t.starts_with("review:")));
let code: Vec<&str> = v.iter().filter(|t| t.starts_with("line ")).map(String::as_str).collect();
assert_eq!(code, (17..=23).map(|n| format!("line {n}")).collect::<Vec<_>>());
assert!(!v.iter().any(|t| t.contains("src/a.rs") || t.contains("new_call")));
assert_eq!(app.rows.iter().filter_map(|r| r.thread).collect::<HashSet<_>>(), HashSet::from([0])); assert!(v.iter().any(|t| t.starts_with("@@ -0,0 +17,7 @@")));
let code_rows = |app: &App| app.rows.iter().filter(|r| r.line.text.starts_with("line ")).count();
app.head.row = app.rows.iter().position(|r| r.line.text == "line 20").unwrap();
app.expand(2);
assert_eq!(code_rows(&app), 11); app.expand(-4);
assert_eq!(code_rows(&app), 3); app.expand(2);
app.goto_comment(2, 0);
assert!(app.view == View::All && app.rows[app.head.row].thread == Some(2));
app.threads[2].comments[0].pending = true;
app.summary_draft = "Nearly there.".into();
app.toggle_view(View::Pending);
let v = texts(&app);
assert!(v.contains(&"your review (pending)".to_owned()) && v.contains(&"Nearly there.".to_owned()), "{v:?}");
assert!(!v.contains(&"Looks fine.".to_owned()) && !v.iter().any(|t| t.contains("big.rs")));
assert_eq!(app.rows.iter().filter_map(|r| r.thread).collect::<HashSet<_>>(), HashSet::from([2]));
app.toggle_view(View::Pending);
assert_eq!(app.view, View::All);
}
#[test]
fn claude_review_is_not_pr_text() {
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() });
app.claude_summary = "Looks fine.".into();
app.build();
let row = app.rows.iter().position(|r| r.line.text == "Looks fine.").unwrap();
app.goto(row, 0);
assert_eq!(app.context_keys().unwrap().0, "Claude's review");
app.key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert!(matches!(app.action.take(), Some(Action::Converse { ref quote, as_is: true }) if quote == "Looks fine.\n"));
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.action.is_none());
assert_eq!(app.summary_draft, "Looks fine.");
}
#[test]
fn replacement_in_the_pr_section() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let o = |number, title: &str| crate::replace::Original { number, title: title.into(), author: "bot".into(), url: format!("https://github.com/o/r/pull/{number}") };
app.show_replacement(crate::replace::Record {
id: 12,
replaces: vec![o(12, "fix"), o(15, "fix again")],
title: "Fix it properly".into(),
body: "Replaces #12 and #15.".into(),
closing_note: "Thanks! Handled in {replacement}.".into(),
..Default::default()
});
app.build();
let texts: Vec<&str> = app.rows.iter().map(|r| r.line.text.as_str()).collect();
assert_eq!(texts[0], "Replacement of #12 · Fix it properly");
assert!(texts.contains(&"replaces #12 fix (@bot), #15 fix again (@bot)"));
assert!(texts.contains(&"Replaces #12 and #15.") && texts.contains(&"Thanks! Handled in {replacement}."));
assert_eq!(app.header_tag(INFO).unwrap().0, " UNPUBLISHED ");
let row = app.rows.iter().find(|r| r.line.text.starts_with("replaces")).unwrap();
assert_eq!(row.links.iter().map(|l| l.2.as_str()).collect::<Vec<_>>(), ["https://github.com/o/r/pull/12", "https://github.com/o/r/pull/15"]); app.claude_path = Some(std::env::temp_dir().join("dv-no-such-review.json"));
app.claude_menu();
assert!(app.picker.take().unwrap().items.iter().any(|i| matches!(i.target, Target::ClaudeReplace(ref p) if p == &[12, 15])));
let s = |app: &mut App| {
app.review_menu();
app.picker.take().unwrap().items.into_iter().map(|i| (i.label, i.target)).collect::<Vec<_>>()
};
let clean = std::env::temp_dir().join(format!("dv-clean-{}", std::process::id()));
std::fs::create_dir_all(&clean).unwrap();
diff::git(&["-C", &clean.to_string_lossy(), "init", "-q"]).unwrap();
app.root = clean.clone();
let items = s(&mut app);
assert!(items[0].0.starts_with("Publish as a draft PR (rebased on origin/") && matches!(items[0].1, Target::Publish(false)));
assert!(items[1].0 == "Publish, then close #12, #15 with the closing note…" && matches!(items[1].1, Target::Publish(true)));
std::fs::write(clean.join("x"), "").unwrap();
let items = s(&mut app);
assert!(items[0].0.starts_with("Commit the uncommitted changes first") && matches!(items[0].1, Target::GitCommit));
assert_eq!(items.len(), 1); _ = std::fs::remove_dir_all(&clean);
let mut r = app.replacement.clone().unwrap();
(r.published, r.closed) = (Some(20), vec![12]);
app.show_replacement(r);
app.build();
let items = s(&mut app);
assert_eq!(items[0].0, "Published as draft PR #20");
assert!(items[1].0 == "Close #15 with the closing note (now)…" && matches!(items[1].1, Target::CloseOriginals));
assert_eq!(app.header_tag(INFO).unwrap().0, " PUBLISHED ");
let row = app.rows.iter().find(|r| r.line.text == "published as draft PR #20").unwrap();
assert_eq!(row.links[0].2, "https://github.com/o/r/pull/20"); }
#[test]
fn range_comment_lights_its_lines() {
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 = gh::Comment { id: 1, node_id: String::new(), can_edit: false, can_delete: false, author: "amy".into(), body: "these".into(), created: String::new(), pending: false };
app.threads = vec![gh::Thread {
id: String::new(), path: "a.rs".into(), left: false, line: Some(4), start_line: Some(2), original_line: Some(4),
resolved: false, outdated: false, hunk: vec![], comments: vec![c], claude: None, severity: String::new(),
}];
app.margin_active = true;
app.build();
let row = |app: &App, text: &str| app.rows.iter().position(|r| r.line.text == text && r.thread.is_none()).unwrap();
let (one, two, three, del, four) = (row(&app, "one"), row(&app, "two"), row(&app, "three"), row(&app, "four"), row(&app, "four!"));
let in_box = app.rows.iter().position(|r| r.thread == Some(0)).unwrap();
app.goto(in_box, 0);
assert!(app.margin_side());
assert_eq!(app.lit_code(), Some((two, four))); assert!(del > two && del < four);
for r in [two, three, four] {
app.goto(r, 0);
assert_eq!(app.lit_thread(), Some(0), "row {r}");
}
app.goto(one, 0);
assert_eq!(app.lit_thread(), None);
}
#[test]
fn gf_on_a_name() {
let mut app = App::with("base".into(), diff::parse(diff::SAMPLE));
let dir = std::env::temp_dir().join(format!("dv-defs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
diff::git(&["-C", &dir.to_string_lossy(), "init", "-q"]).unwrap();
std::fs::write(dir.join("t:é.rs"), "use x;\n\n/* é */ fn torn_prefix_file() {}\nfn other() { torn_prefix_file() }\n").unwrap(); std::fs::write(dir.join("u.py"), "def two_defs(): pass\nclass two_defs: pass\n").unwrap();
app.root = dir.clone();
app.goto_definition("torn_prefix_file"); assert!(matches!(&app.action, Some(Action::Open(o)) if o.ends_with("/t:é.rs:3:12")), "{:?}", app.msg);
app.goto_definition("two_defs"); assert_eq!(app.picker.take().unwrap().items.len(), 2);
app.goto_definition("nowhere");
assert_eq!(app.msg, "no definition of nowhere found");
_ = std::fs::remove_dir_all(&dir);
}