a73x

b673d1db

Merge branch 'worktree-agent-a7c766bb'

a73x   2026-03-21 16:21


diff --git a/tests/editor_test.rs b/tests/editor_test.rs
new file mode 100644
index 0000000..577a913
--- /dev/null
+++ b/tests/editor_test.rs
@@ -0,0 +1,354 @@
//! End-to-end tests for the editor integration module.
//!
//! These tests exercise the public API of `git_collab::editor`, including
//! real process spawning (using `true`/`false` as stand-in editors) and
//! the `find_comment_at_scroll` helper used by the TUI.
//!
//! Tests that mutate environment variables (EDITOR, VISUAL) must not run
//! in parallel with each other. We serialise them via a process-wide mutex.

use std::io::Write;
use std::sync::Mutex;

use git_collab::editor::{find_comment_at_scroll, open_editor_at, resolve_editor};

/// Guard to serialise tests that mutate EDITOR / VISUAL env vars.
static ENV_LOCK: Mutex<()> = Mutex::new(());

// ===========================================================================
// resolve_editor — end-to-end (reads real env vars)
// ===========================================================================

#[test]
fn e2e_resolve_editor_returns_visual_when_set() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::set_var("VISUAL", "nvim");
    std::env::set_var("EDITOR", "vi");

    let result = resolve_editor();
    assert_eq!(result, Some("nvim".to_string()));

    // Restore
    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_resolve_editor_falls_back_to_editor_when_visual_unset() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::remove_var("VISUAL");
    std::env::set_var("EDITOR", "nano");

    let result = resolve_editor();
    assert_eq!(result, Some("nano".to_string()));

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_resolve_editor_none_when_both_unset() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::remove_var("VISUAL");
    std::env::remove_var("EDITOR");

    let result = resolve_editor();
    assert_eq!(result, None);

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_resolve_editor_skips_empty_visual() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::set_var("VISUAL", "");
    std::env::set_var("EDITOR", "emacs");

    let result = resolve_editor();
    assert_eq!(result, Some("emacs".to_string()));

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

// ===========================================================================
// open_editor_at — end-to-end (spawns real processes)
// ===========================================================================

#[test]
fn e2e_open_editor_at_succeeds_with_true() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    // `true` exits 0 and ignores all arguments
    std::env::set_var("EDITOR", "true");
    std::env::remove_var("VISUAL");

    let mut tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp, "some content").unwrap();
    let path = tmp.path().to_str().unwrap();

    let result = open_editor_at(path, 10);
    assert!(result.is_ok(), "Expected Ok, got {:?}", result);

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_open_editor_at_reports_nonzero_exit() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    // `false` exits 1
    std::env::set_var("EDITOR", "false");
    std::env::remove_var("VISUAL");

    let mut tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp, "some content").unwrap();
    let path = tmp.path().to_str().unwrap();

    let result = open_editor_at(path, 1);
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(msg.contains("Editor exited with status"), "got: {}", msg);

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_open_editor_at_errors_when_no_editor_configured() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::remove_var("VISUAL");
    std::env::remove_var("EDITOR");

    let mut tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp, "content").unwrap();
    let path = tmp.path().to_str().unwrap();

    let result = open_editor_at(path, 1);
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(
        msg.contains("No editor configured"),
        "Expected 'No editor configured', got: {}",
        msg
    );

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_open_editor_at_file_not_found() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::set_var("EDITOR", "true");
    std::env::remove_var("VISUAL");

    let result = open_editor_at("/tmp/nonexistent_editor_e2e_test_file.txt", 1);
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(msg.contains("File not found"), "got: {}", msg);

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_open_editor_at_bad_binary() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    std::env::set_var("EDITOR", "nonexistent_binary_xyz_999");
    std::env::remove_var("VISUAL");

    let mut tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp, "content").unwrap();
    let path = tmp.path().to_str().unwrap();

    let result = open_editor_at(path, 1);
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(
        msg.contains("Failed to launch editor"),
        "Expected launch failure, got: {}",
        msg
    );

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_open_editor_at_with_multi_word_editor() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    // Simulate `code --wait` style editor; `true` ignores extra args
    std::env::set_var("EDITOR", "true --wait");
    std::env::remove_var("VISUAL");

    let mut tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp, "content").unwrap();
    let path = tmp.path().to_str().unwrap();

    let result = open_editor_at(path, 42);
    assert!(result.is_ok(), "Expected Ok with multi-word editor, got {:?}", result);

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

#[test]
fn e2e_open_editor_at_visual_takes_precedence_over_editor() {
    let _guard = ENV_LOCK.lock().unwrap();
    let old_visual = std::env::var("VISUAL").ok();
    let old_editor = std::env::var("EDITOR").ok();

    // VISUAL=true should be used instead of EDITOR=false
    // If EDITOR were used, the test would fail because `false` exits 1
    std::env::set_var("VISUAL", "true");
    std::env::set_var("EDITOR", "false");

    let mut tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp, "content").unwrap();
    let path = tmp.path().to_str().unwrap();

    let result = open_editor_at(path, 1);
    assert!(
        result.is_ok(),
        "VISUAL=true should take precedence over EDITOR=false, got {:?}",
        result
    );

    restore_env("VISUAL", old_visual);
    restore_env("EDITOR", old_editor);
}

// ===========================================================================
// find_comment_at_scroll — realistic scenarios
// ===========================================================================

#[test]
fn e2e_find_comment_at_scroll_empty_list() {
    let comments: Vec<(String, u32, usize)> = vec![];
    assert_eq!(find_comment_at_scroll(&comments, 50), None);
}

#[test]
fn e2e_find_comment_at_scroll_single_exact() {
    let comments = vec![("src/main.rs".to_string(), 10, 20)];
    let result = find_comment_at_scroll(&comments, 20);
    assert_eq!(result, Some(("src/main.rs".to_string(), 10)));
}

#[test]
fn e2e_find_comment_at_scroll_single_above() {
    let comments = vec![("src/lib.rs".to_string(), 5, 10)];
    let result = find_comment_at_scroll(&comments, 50);
    assert_eq!(result, Some(("src/lib.rs".to_string(), 5)));
}

#[test]
fn e2e_find_comment_at_scroll_single_below() {
    let comments = vec![("src/lib.rs".to_string(), 5, 100)];
    let result = find_comment_at_scroll(&comments, 50);
    assert_eq!(result, None);
}

#[test]
fn e2e_find_comment_at_scroll_picks_closest_above_from_many() {
    // Simulate a diff with inline comments scattered across files
    let comments = vec![
        ("src/editor.rs".to_string(), 12, 5),
        ("src/editor.rs".to_string(), 30, 15),
        ("src/tui.rs".to_string(), 100, 40),
        ("src/tui.rs".to_string(), 150, 60),
        ("src/main.rs".to_string(), 1, 80),
    ];

    // Scroll at 45 — closest at-or-above is rendered_pos=40 (src/tui.rs:100)
    let result = find_comment_at_scroll(&comments, 45);
    assert_eq!(result, Some(("src/tui.rs".to_string(), 100)));
}

#[test]
fn e2e_find_comment_at_scroll_at_boundary_zero() {
    let comments = vec![
        ("header.rs".to_string(), 1, 0),
        ("body.rs".to_string(), 10, 20),
    ];
    let result = find_comment_at_scroll(&comments, 0);
    assert_eq!(result, Some(("header.rs".to_string(), 1)));
}

#[test]
fn e2e_find_comment_at_scroll_all_at_same_position() {
    // Multiple comments at the same rendered position — first wins
    let comments = vec![
        ("a.rs".to_string(), 1, 10),
        ("b.rs".to_string(), 2, 10),
        ("c.rs".to_string(), 3, 10),
    ];
    let result = find_comment_at_scroll(&comments, 10);
    assert_eq!(result, Some(("a.rs".to_string(), 1)));
}

#[test]
fn e2e_find_comment_at_scroll_large_scroll_position() {
    let comments = vec![
        ("early.rs".to_string(), 1, 10),
        ("late.rs".to_string(), 999, 500),
    ];
    // Scroll way past all comments — should return the last one
    let result = find_comment_at_scroll(&comments, u16::MAX);
    assert_eq!(result, Some(("late.rs".to_string(), 999)));
}

#[test]
fn e2e_find_comment_at_scroll_unsorted_input() {
    // Comments may not arrive sorted by rendered position
    let comments = vec![
        ("c.rs".to_string(), 30, 50),
        ("a.rs".to_string(), 10, 5),
        ("b.rs".to_string(), 20, 25),
    ];
    // Scroll at 30 — closest at-or-above is rendered_pos=25
    let result = find_comment_at_scroll(&comments, 30);
    assert_eq!(result, Some(("b.rs".to_string(), 20)));
}

// ===========================================================================
// Helpers
// ===========================================================================

fn restore_env(var: &str, old: Option<String>) {
    match old {
        Some(val) => std::env::set_var(var, val),
        None => std::env::remove_var(var),
    }
}