a73x

tests/abbrev_test.rs

Ref:   Size: 29.3 KiB   History

//! The id-display policy, end to end.
//!
//! The unit tests in `src/abbrev.rs` cover the width arithmetic. These cover
//! the two promises the CLI makes about it: an id it prints can always be
//! typed straight back in, and a prefix that names more than one object is
//! refused rather than resolved.
//!
//! The `web ui` and `dashboard` sections at the bottom hold the same promises
//! to the other two surfaces, which have their own way of getting them wrong:
//! both render a *filtered* subset, and a width computed from what is on
//! screen is not the width the policy asks for.

mod common;

use common::{ServerHarness, TestRepo};

/// Pull the leading id column out of a `list` line.
fn first_column(line: &str) -> &str {
    line.split_whitespace().next().unwrap_or("")
}

fn open_issues(repo: &TestRepo, n: usize) -> Vec<String> {
    (0..n)
        .map(|i| {
            let title = format!("issue {}", i);
            let out = repo.run_ok(&["issue", "open", "-t", &title]);
            out.trim()
                .rsplit(' ')
                .next()
                .expect("open should print an id")
                .to_string()
        })
        .collect()
}

/// The floor stays at 8, so nothing that reads today's output changes.
#[test]
fn a_small_repo_still_prints_eight_character_ids() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 3);
    let out = repo.run_ok(&["issue", "list"]);
    for line in out.lines().filter(|l| !l.trim().is_empty()) {
        assert_eq!(
            first_column(line).len(),
            8,
            "expected an 8-character id in: {}",
            line
        );
    }
}

/// Anything printed must be typeable. This is the whole contract: a displayed
/// id is a reference someone will paste back, into a command or a commit
/// message, and it has to work when they do.
#[test]
fn every_id_printed_by_list_resolves_on_its_own() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 12);

    let listed = repo.run_ok(&["issue", "list"]);
    let shown: Vec<String> = listed
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| first_column(l).to_string())
        .collect();
    assert_eq!(shown.len(), 12, "expected 12 listed issues: {}", listed);

    for id in &shown {
        // Resolving is the assertion; run_ok panics on a non-zero exit, which
        // is what an ambiguous or unknown prefix produces.
        let detail = repo.run_ok(&["issue", "show", id]);
        assert!(
            detail.contains(id),
            "showing '{}' should echo it back: {}",
            id,
            detail
        );
    }
}

/// The ids in `issue list` and `issue show` must agree, or the id someone
/// copies depends on which command they happened to run.
#[test]
fn list_and_show_print_the_same_width() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let ids = open_issues(&repo, 5);

    let listed = repo.run_ok(&["issue", "list"]);
    let listed_width = first_column(listed.lines().next().unwrap()).len();

    let shown = repo.run_ok(&["issue", "show", &ids[0]]);
    let header = shown.lines().next().unwrap();
    let shown_id = header
        .strip_prefix("Issue ")
        .and_then(|rest| rest.split_whitespace().next())
        .expect("show should start with an Issue header");
    assert_eq!(shown_id.len(), listed_width, "header was: {}", header);
}

/// The abbreviation is computed over every issue of that kind, so `--all`
/// cannot print a different string for the same issue than the default view.
#[test]
fn closed_issues_do_not_change_the_width_of_open_ones() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let ids = open_issues(&repo, 6);
    let before = repo.run_ok(&["issue", "list"]);
    let before_id = first_column(before.lines().next().unwrap()).to_string();

    for id in ids.iter().take(3) {
        repo.run_ok(&["issue", "close", id]);
    }

    let after = repo.run_ok(&["issue", "list"]);
    let after_ids: Vec<&str> = after
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(first_column)
        .collect();
    assert!(
        after_ids.iter().all(|id| id.len() == before_id.len()),
        "closing issues changed the printed width: {}",
        after
    );
}

/// The other half of the policy: a prefix short enough to name two objects
/// must fail at the point of use. Twenty issues over sixteen possible first
/// characters guarantees a shared one.
#[test]
fn an_ambiguous_prefix_fails_loudly_instead_of_picking_one() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let ids = open_issues(&repo, 20);

    let mut seen: std::collections::HashMap<char, usize> = std::collections::HashMap::new();
    for id in &ids {
        *seen.entry(id.chars().next().unwrap()).or_default() += 1;
    }
    let (shared, count) = seen
        .iter()
        .find(|(_, n)| **n > 1)
        .expect("20 ids over 16 first characters must share one");

    let err = repo.run_err(&["issue", "show", &shared.to_string()]);
    assert!(
        err.contains("ambiguous"),
        "a prefix matching {} issues should be refused as ambiguous, got: {}",
        count,
        err
    );
}

/// A prefix shorter than the displayed width still works when it happens to be
/// unique — accepting prefixes and displaying them are separate concerns, and
/// the accepting side stays as permissive as it was.
#[test]
fn a_unique_short_prefix_is_still_accepted() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let ids = open_issues(&repo, 1);
    let short = &ids[0][..3];
    let out = repo.run_ok(&["issue", "show", short]);
    assert!(out.starts_with("Issue "), "got: {}", out);
}

// ---------------------------------------------------------------------------
// `collab.abbrev`, this tool's `core.abbrev`.
//
// These also serve as the proof that every display site actually goes through
// the abbreviator rather than a hard-coded 8: a repository small enough to
// test by hand can never produce a natural collision, so the width has to be
// forced to see the wiring at all.
// ---------------------------------------------------------------------------

fn set_abbrev(repo: &TestRepo, value: &str) {
    let status = std::process::Command::new("git")
        .args(["config", "collab.abbrev", value])
        .current_dir(repo.dir.path())
        .status()
        .expect("failed to set config");
    assert!(status.success());
}

#[test]
fn collab_abbrev_sets_the_width_of_every_list() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 3);
    set_abbrev(&repo, "14");

    let listed = repo.run_ok(&["issue", "list"]);
    for line in listed.lines().filter(|l| !l.trim().is_empty()) {
        assert_eq!(
            first_column(line).len(),
            14,
            "issue list ignored collab.abbrev: {}",
            line
        );
    }
}

#[test]
fn collab_abbrev_sets_the_width_of_show_and_status() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let ids = open_issues(&repo, 2);
    set_abbrev(&repo, "14");

    let shown = repo.run_ok(&["issue", "show", &ids[0]]);
    let header = shown.lines().next().unwrap();
    let shown_id = header
        .strip_prefix("Issue ")
        .and_then(|r| r.split_whitespace().next())
        .expect("show should start with an Issue header");
    assert_eq!(shown_id.len(), 14, "show ignored collab.abbrev: {}", header);

    let status = repo.run_ok(&["status"]);
    let recent = status
        .lines()
        .find(|l| l.trim_start().starts_with("[issue]"))
        .expect("status should list a recent issue");
    let status_id = recent.split_whitespace().nth(1).unwrap();
    assert_eq!(
        status_id.len(),
        14,
        "status ignored collab.abbrev: {}",
        recent
    );
}

#[test]
fn collab_abbrev_applies_to_patches_too() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feature"]);
    std::fs::write(repo.dir.path().join("a.txt"), "hello").unwrap();
    repo.git(&["add", "a.txt"]);
    repo.git(&["commit", "-m", "work"]);
    repo.run_ok(&["patch", "create", "-t", "a patch", "--base", "main"]);
    set_abbrev(&repo, "14");

    let listed = repo.run_ok(&["patch", "list"]);
    for line in listed.lines().filter(|l| !l.trim().is_empty()) {
        assert_eq!(
            first_column(line).len(),
            14,
            "patch list ignored collab.abbrev: {}",
            line
        );
    }
}

/// `no` is git's spelling for "do not abbreviate".
#[test]
fn collab_abbrev_no_prints_whole_ids() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 2);
    set_abbrev(&repo, "no");

    let listed = repo.run_ok(&["issue", "list"]);
    for line in listed.lines().filter(|l| !l.trim().is_empty()) {
        assert_eq!(first_column(line).len(), 40, "line: {}", line);
    }
}

/// git clamps `core.abbrev` to a minimum of 4; below that the setting stops
/// being a display preference and starts producing ids nobody can use.
#[test]
fn collab_abbrev_is_clamped_to_a_usable_minimum() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 2);
    set_abbrev(&repo, "1");

    let listed = repo.run_ok(&["issue", "list"]);
    for line in listed.lines().filter(|l| !l.trim().is_empty()) {
        assert_eq!(
            first_column(line).len(),
            4,
            "collab.abbrev=1 should clamp to 4: {}",
            line
        );
    }
}

/// A narrowed width is still never allowed to print an ambiguous id: the
/// per-id widening runs on top of whatever width is configured.
#[test]
fn a_narrow_configured_width_still_prints_resolvable_ids() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 20);
    set_abbrev(&repo, "4");

    let listed = repo.run_ok(&["issue", "list"]);
    for line in listed.lines().filter(|l| !l.trim().is_empty()) {
        let id = first_column(line);
        repo.run_ok(&["issue", "show", id]);
    }
}

/// Nonsense in the config falls back to the automatic width rather than
/// breaking every command that prints an id.
#[test]
fn an_unparseable_collab_abbrev_falls_back_to_the_automatic_width() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    open_issues(&repo, 2);
    set_abbrev(&repo, "banana");

    let listed = repo.run_ok(&["issue", "list"]);
    for line in listed.lines().filter(|l| !l.trim().is_empty()) {
        assert_eq!(first_column(line).len(), 8, "line: {}", line);
    }
}

// ---------------------------------------------------------------------------
// The set the width is computed from
// ---------------------------------------------------------------------------

/// The abbreviation has to be computed over every object of a kind. If it were
/// computed over just the rows being listed, `issue list` and
/// `issue list --all` could print different strings for the same issue, and a
/// displayed id could name two objects the moment a different flag is used.
#[test]
fn the_id_set_covers_closed_and_archived_issues() {
    use git2::Repository;

    let repo = TestRepo::new("Alice", "alice@example.com");
    let ids = open_issues(&repo, 3);
    repo.run_ok(&["issue", "close", &ids[1]]);

    let git = Repository::open(repo.dir.path()).unwrap();
    let (_, full) = git_collab::state::resolve_issue_ref(&git, &ids[2]).unwrap();
    git_collab::state::archive_issue_ref(&git, &full).unwrap();

    let all = git_collab::state::all_issue_ids(&git).unwrap();
    assert_eq!(all.len(), 3, "closed and archived ids must be included");
    for id in &ids {
        assert!(
            all.iter().any(|found| found.starts_with(id)),
            "'{}' missing from the id set: {:?}",
            id,
            all
        );
    }
}

#[test]
fn the_id_set_covers_archived_patches() {
    use git2::Repository;

    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.git(&["checkout", "-b", "feature"]);
    std::fs::write(repo.dir.path().join("a.txt"), "hello").unwrap();
    repo.git(&["add", "a.txt"]);
    repo.git(&["commit", "-m", "work"]);
    let out = repo.run_ok(&["patch", "create", "-t", "a patch", "--base", "main"]);
    let short = out.trim().rsplit(' ').next().unwrap().to_string();

    let git = Repository::open(repo.dir.path()).unwrap();
    let (_, full) = git_collab::state::resolve_patch_ref(&git, &short).unwrap();
    git_collab::state::archive_patch_ref(&git, &full).unwrap();

    let all = git_collab::state::all_patch_ids(&git).unwrap();
    assert!(
        all.contains(&full),
        "archived patch '{}' missing from {:?}",
        full,
        all
    );
}

// ---------------------------------------------------------------------------
// The other two surfaces
//
// The web UI and the dashboard both render a *filtered* subset — open issues,
// merged patches, whatever the current view asks for. The policy is computed
// over every object of a kind, so a filtered view has to be abbreviated at a
// width it cannot derive from its own rows. These tests are written to fail
// against a surface that abbreviates over what it is showing.
// ---------------------------------------------------------------------------

/// Give `real_id` a twin that shares its first eight characters.
///
/// Eight is the floor width, so two ids only collide there in a repository far
/// too large to build in a test. Planting the collision is the only way to see
/// the widening at all, and an *archived* twin is the honest place to plant
/// it: archived objects are in the id set the policy is computed over and in
/// none of the views that list objects. A surface that abbreviates over the
/// rows it is showing prints eight characters here, and is wrong.
///
/// Returns the twin's id. The real id must now be printed nine characters
/// wide: identical through the floor width, distinct one character later.
fn plant_archived_issue_twin(git: &git2::Repository, real_id: &str) -> String {
    let mut chars: Vec<char> = real_id.chars().collect();
    chars[8] = if chars[8] == '0' { '1' } else { '0' };
    let twin: String = chars.into_iter().collect();
    let target = git
        .refname_to_id(&format!("refs/collab/issues/{}", real_id))
        .expect("the real issue ref should exist");
    git.reference(
        &format!("refs/collab/archive/issues/{}", twin),
        target,
        false,
        "planted twin",
    )
    .unwrap();
    twin
}

/// The full 40-character id behind whatever prefix a command printed.
fn full_issue_id(git: &git2::Repository, prefix: &str) -> String {
    git_collab::state::resolve_issue_ref(git, prefix).unwrap().1
}

// ---------------------------------------------------------------------------
// web ui
// ---------------------------------------------------------------------------

fn set_server_abbrev(harness: &ServerHarness, value: &str) {
    let repo_dir = harness
        .repos_dir()
        .join(format!("{}.git", harness.repo_name()));
    common::git_cmd(&repo_dir, &["config", "collab.abbrev", value]);
}

/// The text of the `<a>` whose href ends in `href_tail`. The list templates
/// link the row's id cell to the object's own page, so this is exactly the
/// string the page offers a reader to copy.
fn anchor_text(body: &str, href_tail: &str) -> String {
    let needle = format!("{}\">", href_tail);
    let start = body
        .find(&needle)
        .unwrap_or_else(|| panic!("no link ending in '{}' in:\n{}", href_tail, body))
        + needle.len();
    let end = body[start..]
        .find("</a>")
        .unwrap_or_else(|| panic!("unterminated link for '{}'", href_tail))
        + start;
    body[start..end].to_string()
}

/// The test the naive fix fails. The issue list defaults to open issues only;
/// the twin that forces the widening is archived and appears on none of these
/// pages. Its absence must not narrow the id.
#[test]
fn a_filtered_issue_page_is_abbreviated_at_the_full_set_width() {
    let harness = ServerHarness::new("abbrev-web-filtered");
    harness.push_head();

    let short = harness.work_repo().issue_open("A visible issue");
    let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap();
    let real_id = full_issue_id(&git, &short);
    let twin = plant_archived_issue_twin(&git, &real_id);
    harness.push_collab_refs();

    let page = harness.get_ok(&format!("/{}/issues?filter=open", harness.repo_name()));
    let shown = anchor_text(&page.body, &format!("/issues/{}", real_id));

    assert_eq!(
        shown.len(),
        9,
        "the open-issue page abbreviated over its own rows instead of every \
         issue: showed '{}' while an archived issue shares its first eight \
         characters",
        shown
    );
    assert!(real_id.starts_with(&shown), "showed '{}'", shown);
    assert!(
        !twin.starts_with(&shown),
        "'{}' names two issues, so it is not a usable reference",
        shown
    );
}

/// The same issue must render as the same string whichever filter is applied,
/// or the id someone copies depends on which tab they happened to be on.
#[test]
fn every_issue_filter_prints_the_same_width() {
    let harness = ServerHarness::new("abbrev-web-agree");
    harness.push_head();

    let short = harness.work_repo().issue_open("A visible issue");
    let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap();
    let real_id = full_issue_id(&git, &short);
    plant_archived_issue_twin(&git, &real_id);
    harness.push_collab_refs();

    let tail = format!("/issues/{}", real_id);
    let open = harness.get_ok(&format!("/{}/issues?filter=open", harness.repo_name()));
    let all = harness.get_ok(&format!("/{}/issues?filter=all", harness.repo_name()));
    let overview = harness.get_ok(&format!("/{}", harness.repo_name()));

    let from_open = anchor_text(&open.body, &tail);
    assert_eq!(from_open, anchor_text(&all.body, &tail), "open vs all");
    assert_eq!(
        from_open,
        anchor_text(&overview.body, &tail),
        "issue list vs repo overview"
    );
}

#[test]
fn collab_abbrev_sets_the_width_of_the_web_issue_and_patch_lists() {
    let harness = ServerHarness::new("abbrev-web-config");
    harness.push_head();

    let issue_short = harness.work_repo().issue_open("Configured issue");
    let patch_short = harness.work_repo().patch_create("Configured patch");
    harness.push_collab_refs();
    set_server_abbrev(&harness, "14");

    let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap();
    let issue_id = full_issue_id(&git, &issue_short);
    let patch_id = git_collab::state::resolve_patch_ref(&git, &patch_short)
        .unwrap()
        .1;

    let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name()));
    assert_eq!(
        anchor_text(&issues.body, &format!("/issues/{}", issue_id)).len(),
        14,
        "the issue list ignored collab.abbrev"
    );

    let patches = harness.get_ok(&format!("/{}/patches", harness.repo_name()));
    assert_eq!(
        anchor_text(&patches.body, &format!("/patches/{}", patch_id)).len(),
        14,
        "the patch list ignored collab.abbrev"
    );

    let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
    assert_eq!(
        anchor_text(&overview.body, &format!("/issues/{}", issue_id)).len(),
        14,
        "the repo overview ignored collab.abbrev"
    );
    assert_eq!(
        anchor_text(&overview.body, &format!("/patches/{}", patch_id)).len(),
        14,
        "the repo overview ignored collab.abbrev"
    );
}

/// `full` is git's spelling for "do not abbreviate", and the web UI has the
/// room to honour it.
#[test]
fn collab_abbrev_full_prints_whole_ids_in_the_web_ui() {
    let harness = ServerHarness::new("abbrev-web-full");
    harness.push_head();

    let issue_short = harness.work_repo().issue_open("Whole id issue");
    harness.push_collab_refs();
    set_server_abbrev(&harness, "full");

    let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap();
    let issue_id = full_issue_id(&git, &issue_short);

    let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name()));
    assert_eq!(
        anchor_text(&issues.body, &format!("/issues/{}", issue_id)),
        issue_id,
        "collab.abbrev=full should print the whole id"
    );
}

// ---------------------------------------------------------------------------
// Reading is not writing
//
// Both surfaces now run an extra query — every id of a kind — on every render.
// It has to stay a query. A page view or a dashboard launch that appended an
// event or moved a ref would be writing to a shared, replicated history on
// behalf of someone who only looked at it.
// ---------------------------------------------------------------------------

/// Every collab ref and what it points at, as a sorted list.
fn collab_refs_snapshot(git_dir: &std::path::Path) -> Vec<String> {
    let out = std::process::Command::new("git")
        .args([
            "for-each-ref",
            "--format=%(refname) %(objectname)",
            "refs/collab/",
        ])
        .current_dir(git_dir)
        .output()
        .expect("for-each-ref failed");
    let mut refs: Vec<String> = String::from_utf8_lossy(&out.stdout)
        .lines()
        .map(|l| l.to_string())
        .collect();
    refs.sort();
    refs
}

#[test]
fn rendering_web_pages_does_not_touch_the_collab_refs() {
    let harness = ServerHarness::new("abbrev-web-readonly");
    harness.push_head();
    harness.work_repo().issue_open("An issue to look at");
    harness.work_repo().patch_create("A patch to look at");
    harness.push_collab_refs();

    let served = harness
        .repos_dir()
        .join(format!("{}.git", harness.repo_name()));
    let before = collab_refs_snapshot(&served);
    assert!(!before.is_empty(), "the test needs some refs to protect");

    let name = harness.repo_name();
    for path in [
        format!("/{}", name),
        format!("/{}/issues", name),
        format!("/{}/issues?filter=all", name),
        format!("/{}/patches", name),
        format!("/{}/patches?filter=all", name),
    ] {
        harness.get_ok(&path);
    }

    assert_eq!(
        before,
        collab_refs_snapshot(&served),
        "rendering a page changed the collab refs"
    );
}

#[test]
fn launching_the_dashboard_does_not_touch_the_collab_refs() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.issue_open("An issue to look at");
    repo.patch_create("A patch to look at");

    let before = collab_refs_snapshot(repo.dir.path());
    assert!(!before.is_empty(), "the test needs some refs to protect");

    // `a` cycles the status filter and `P` swaps to patches, so this walks
    // the views that re-derive a width, not just the one on startup.
    let output = repo.run_dashboard_smoke_sized("aPaq", 120, 24);
    assert!(output.status.success(), "dashboard should exit cleanly");

    assert_eq!(
        before,
        collab_refs_snapshot(repo.dir.path()),
        "opening the dashboard changed the collab refs"
    );
}

// ---------------------------------------------------------------------------
// patch checkout
//
// The branch `patch checkout` creates is named after the patch, so the branch
// name is a displayed id like any other — and the one displayed id that
// outlives the command by definition, since the branch stays behind.
// ---------------------------------------------------------------------------

/// The patch equivalent of `plant_archived_issue_twin`.
fn plant_archived_patch_twin(git: &git2::Repository, real_id: &str) -> String {
    let mut chars: Vec<char> = real_id.chars().collect();
    chars[8] = if chars[8] == '0' { '1' } else { '0' };
    let twin: String = chars.into_iter().collect();
    let target = git
        .refname_to_id(&format!("refs/collab/patches/{}/events", real_id))
        .expect("the real patch ref should exist");
    git.reference(
        &format!("refs/collab/archive/patches/{}/events", twin),
        target,
        false,
        "planted twin",
    )
    .unwrap();
    twin
}

/// Two patches sharing eight characters must not want the same branch name.
///
/// The suffix loop in `checkout` would paper over it — `collab/<id>-1` — but
/// that name reads as a second checkout of one patch rather than a checkout of
/// a different one, and the reviewer has no way to tell which patch they are
/// looking at from the branch they are on.
#[test]
fn patch_checkout_names_the_branch_at_the_full_set_width() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = repo.patch_create("A patch to check out");
    let git = git2::Repository::open(repo.dir.path()).unwrap();
    let real_id = git_collab::state::resolve_patch_ref(&git, &short)
        .unwrap()
        .1;
    let twin = plant_archived_patch_twin(&git, &real_id);

    let out = repo.run_ok(&["patch", "checkout", &real_id]);
    let branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]);
    let branch = branch.trim();

    assert_eq!(
        branch,
        format!("collab/{}", &real_id[..9]),
        "the branch name was cut to a prefix that also names patch {}",
        &twin[..9]
    );
    assert!(
        out.contains(&real_id[..9]),
        "the message should name the patch at the same width: {}",
        out
    );
}

#[test]
fn collab_abbrev_sets_the_width_of_the_patch_checkout_branch() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = repo.patch_create("A configured patch");
    let git = git2::Repository::open(repo.dir.path()).unwrap();
    let real_id = git_collab::state::resolve_patch_ref(&git, &short)
        .unwrap()
        .1;
    set_abbrev(&repo, "14");

    repo.run_ok(&["patch", "checkout", &real_id]);
    let branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]);

    assert_eq!(branch.trim(), format!("collab/{}", &real_id[..14]));
}

// ---------------------------------------------------------------------------
// dashboard
//
// The TUI is driven through a pty, so what these assert on is the characters
// that actually reached the screen.
// ---------------------------------------------------------------------------

/// The printable text of a terminal session, with the escape sequences that
/// carried the colours removed.
fn strip_ansi(raw: &str) -> String {
    let mut out = String::new();
    let mut chars = raw.chars().peekable();
    while let Some(c) = chars.next() {
        if c != '\u{1b}' {
            out.push(c);
            continue;
        }
        // CSI sequences run until a byte in @-~; anything else is a two-byte
        // escape. Neither ever contains printable payload the dashboard means
        // for a reader, so both are dropped whole.
        if chars.peek() == Some(&'[') {
            chars.next();
            for c2 in chars.by_ref() {
                if ('@'..='~').contains(&c2) {
                    break;
                }
            }
        } else {
            chars.next();
        }
    }
    out
}

fn dashboard_screen(repo: &TestRepo, cols: u16) -> String {
    let output = repo.run_dashboard_smoke_sized("q", cols, 24);
    assert!(
        output.status.success(),
        "dashboard did not exit cleanly: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    strip_ansi(&String::from_utf8_lossy(&output.stdout))
}

/// Open one issue and give it an archived twin sharing its first eight
/// characters. Returns the issue's full id.
fn issue_with_archived_twin(repo: &TestRepo, title: &str) -> (String, String) {
    let short = repo.issue_open(title);
    let git = git2::Repository::open(repo.dir.path()).unwrap();
    let real_id = full_issue_id(&git, &short);
    let twin = plant_archived_issue_twin(&git, &real_id);
    (real_id, twin)
}

/// The dashboard's counterpart to the web test above. Its issue list defaults
/// to open issues and never shows archived ones at all, yet the archived twin
/// still has to widen what it prints.
#[test]
fn the_dashboard_issue_list_is_abbreviated_at_the_full_set_width() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let (real_id, twin) = issue_with_archived_twin(&repo, "Dashboard issue");

    let screen = dashboard_screen(&repo, 120);

    assert!(
        screen.contains(&format!("{}  ", &real_id[..9])),
        "expected the issue id widened to nine characters ('{}') on screen:\n{}",
        &real_id[..9],
        screen
    );
    assert!(
        !screen.contains(&format!("{}  ", &real_id[..8])),
        "the dashboard printed '{}', which also names the archived issue {}",
        &real_id[..8],
        &twin[..9]
    );
}

#[test]
fn collab_abbrev_sets_the_width_of_the_dashboard_lists() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = repo.issue_open("Configured issue");
    let git = git2::Repository::open(repo.dir.path()).unwrap();
    let issue_id = full_issue_id(&git, &short);
    set_abbrev(&repo, "14");

    let screen = dashboard_screen(&repo, 120);
    assert!(
        screen.contains(&format!("{}  ", &issue_id[..14])),
        "the dashboard ignored collab.abbrev=14:\n{}",
        screen
    );
}

#[test]
fn collab_abbrev_full_prints_whole_ids_in_the_dashboard() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let short = repo.issue_open("Whole id issue");
    let git = git2::Repository::open(repo.dir.path()).unwrap();
    let issue_id = full_issue_id(&git, &short);
    set_abbrev(&repo, "full");

    // Wide enough that a 40-character id is not merely clipped by the pane.
    let screen = dashboard_screen(&repo, 200);
    assert!(
        screen.contains(&issue_id),
        "collab.abbrev=full should print the whole id '{}':\n{}",
        issue_id,
        screen
    );
}