a73x

tests/tui_review_test.rs

Ref:   Size: 29.5 KiB   History

//! The dashboard as a review surface, driven through a real pty.
//!
//! Two properties are only true end to end and so are only testable here: that
//! suspending to `$EDITOR` leaves the alternate screen, runs a process, comes
//! back and records what the process wrote; and that a ref another process
//! moved is noticed while the dashboard is sitting there. Both are checked by
//! looking at the collab DAG afterwards rather than at the screen, because the
//! screen can lie and the DAG cannot.

mod common;

use common::{screen_text, TestRepo};
use std::thread;
use std::time::Duration;

/// A fake `$EDITOR` that replaces the buffer with `body` and exits cleanly.
fn editor_writing(repo: &TestRepo, name: &str, body: &str) -> String {
    repo.write_script(
        name,
        &format!(
            "#!/bin/sh\ncat > \"$1\" <<'GITCOLLABEOF'\n{}\nGITCOLLABEOF\n",
            body
        ),
    )
}

/// Seed a repo with one patch that has a real revision to comment on.
fn repo_with_patch() -> (TestRepo, String) {
    let repo = TestRepo::new("Reviewer", "reviewer@example.com");
    repo.commit_file("src/lib.rs", "one\ntwo\nthree\nfour\n", "seed");
    repo.git(&["checkout", "-b", "feature/x"]);
    repo.commit_file(
        "src/lib.rs",
        "one\ntwo changed\nthree\nfour\nfive\n",
        "work",
    );
    let out = repo.run_ok(&[
        "patch",
        "create",
        "-t",
        "A patch to review",
        "-B",
        "feature/x",
    ]);
    let id = out
        .trim()
        .strip_prefix("Created patch ")
        .expect("patch create output")
        .to_string();
    repo.git(&["checkout", "main"]);
    (repo, id)
}

// ── Staleness (59ab40e8) ────────────────────────────────────────────────────

/// The property that turns the old behaviour from a limitation into a bug:
/// a screen that is out of date has to say when it was loaded.
#[test]
fn dashboard_always_shows_when_it_was_loaded() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    repo.issue_open("something to look at");

    let out = repo.run_dashboard_smoke_sized("q", 100, 24);
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("as of "),
        "no 'as of' marker on the dashboard:\n{}",
        text
    );
}

/// A ref another process moves while the dashboard sits there must be
/// announced. The dashboard must not reload under the reader — the banner is
/// the whole feature — so the assertion is on the banner, not on the list.
#[test]
fn an_external_write_raises_the_banner() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    repo.issue_open("the one already on screen");

    let out = repo.run_dashboard_driven(100, 24, &[], |dash| {
        // The dashboard is up and has folded what exists. Now be the agent
        // that lands something while the reviewer is reading. `repo.run_ok`
        // targets the temp repo and its isolated home — running the binary by
        // hand here would write to whatever directory the test happens to be
        // started from.
        repo.issue_open("landed while you were reading");
        dash.wait_for("new event");
        dash.send("q");
    });
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("new event"),
        "no staleness banner after an external write:\n{}",
        text
    );
}

/// `n` writes its body through `$EDITOR` too — there is no second, worse text
/// input left in the program.
#[test]
fn n_composes_the_issue_body_in_the_editor() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    let editor = editor_writing(&repo, "issue-body.sh", "the long markdown body");

    repo.run_dashboard_driven(100, 24, &[("EDITOR", &editor)], |dash| {
        dash.press("nA new issue\r", "Issue created");
        dash.send("q");
    });

    let listed = repo.run_ok(&["issue", "list"]);
    let id = listed
        .split_whitespace()
        .next()
        .expect("one issue was opened");
    let shown = repo.run_ok(&["issue", "show", id]);
    assert!(
        shown.contains("A new issue") && shown.contains("the long markdown body"),
        "the editor's body did not reach the issue:\n{}",
        shown
    );
}

/// The reader's own comment must not be announced back to them as news. The
/// write reloads, and that reload has to re-baseline the tip snapshot.
#[test]
fn the_dashboards_own_write_does_not_raise_the_banner() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    repo.issue_open("existing");
    let editor = editor_writing(&repo, "issue-editor.sh", "a body for it");

    let out = repo.run_dashboard_driven(100, 24, &[("EDITOR", &editor)], |dash| {
        // `n`, a title, Enter — which opens the editor for the body.
        dash.press("nmine\r", "Issue created");
        // The claim here is an absence, and an absence has nothing to wait
        // for. The dashboard compares collab tips every two seconds, so
        // sitting still for longer than one of those periods is what makes
        // "no banner" mean "no banner was ever going to be raised" rather
        // than "we did not stay long enough to see it". This is the only
        // wall-clock wait left in the file, and it is bounded by the
        // program's own poll interval rather than by a guess about the
        // machine.
        thread::sleep(Duration::from_millis(2500));
        dash.send("q");
    });
    let text = screen_text(&out.stdout);
    // Vacuously true if nothing was written, so check the write landed first.
    let issues = repo.run_ok(&["issue", "list"]);
    assert!(
        issues.contains("mine"),
        "the dashboard never made the write this is about:\n{}",
        issues
    );
    assert!(
        !text.contains("new event"),
        "the dashboard announced its own write as external:\n{}",
        text
    );
}

// ── Wrapping the patch detail pane (9f9dee31) ───────────────────────────────

/// The defect in a real terminal: a diff line wider than the pane used to end
/// at the right edge with nothing to say the rest existed. The end of the line
/// is a distinct token, so its presence on screen is the whole property — and
/// it is checked through a pty because the clipping was a property of the pane
/// as drawn, not of the row list.
#[test]
fn a_diff_line_wider_than_the_pane_is_not_clipped_away() {
    let repo = TestRepo::new("Reviewer", "reviewer@example.com");
    repo.commit_file("src/lib.rs", "one\ntwo\n", "seed");
    repo.git(&["checkout", "-b", "feature/wide"]);
    // Far wider than the ~63 columns the detail pane gets at 100 across, with
    // the far end named so the test can look for it rather than for a length.
    let wide = format!("let x = \"{}\"; // ENDOFTHEWIDELINE", "w".repeat(300));
    repo.commit_file(
        "src/lib.rs",
        &format!("one\ntwo\n{}\n", wide),
        "a wide line",
    );
    repo.run_ok(&[
        "patch",
        "create",
        "-t",
        "A wide patch",
        "-B",
        "feature/wide",
    ]);
    repo.git(&["checkout", "main"]);

    let out = repo.run_dashboard_driven(100, 45, &[], |dash| {
        dash.press("P", "Patches (open)");
        // Waiting for the far end of the line is not the assertion: if it
        // never arrives the wait gives up and the assertion below fails with
        // the screen attached, which is the same failure as before, sooner.
        dash.press("\r", "ENDOFTHEWIDELINE");
        dash.send("q");
    });
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("ENDOFTHEWIDELINE"),
        "the end of a 300-column diff line never reached the screen:\n{}",
        text
    );
}

// ── Timestamps (952390ad) ───────────────────────────────────────────────────

/// The defect as it was captured: a real pty, a real repository, and
/// `Created: 2026-08-12T16:57:16.325870642+00:00` on the issue pane.
///
/// Both halves are asserted from one screen. Stored timestamps are RFC3339
/// with a `+00:00` offset and nothing else the dashboard prints ends that way,
/// so the offset's absence covers every pane the walk visits at once; and the
/// short form has to be positively there, or dropping the field entirely would
/// satisfy it. The expected value comes from the stored one the CLI prints
/// rather than from the clock, so nothing here races midnight.
#[test]
fn no_pane_prints_a_stored_timestamp_in_full() {
    let (repo, id) = repo_with_patch();
    repo.issue_open("something with a date on it");
    repo.run_ok(&["patch", "comment", &id, "-b", "a remark with a date on it"]);

    let listed = repo.run_ok(&["issue", "list"]);
    let issue = listed.split_whitespace().next().expect("one issue");
    let shown = repo.run_ok(&["issue", "show", issue]);
    let stored = shown
        .lines()
        .find_map(|l| l.strip_prefix("Created: "))
        .expect("issue show prints the stored timestamp")
        .trim()
        .to_string();
    // `2026-08-13T07:33:57.151572101+00:00` -> `2026-08-1307:33`. The space
    // between the date and the time is left out on purpose: ratatui writes only
    // the cells that differ from what is already there, and a cell holding a
    // space starts out holding a space — so an interior blank never reaches the
    // stream this reads. Everything either side of it does.
    let expected = format!("{}{}", &stored[..10], &stored[11..16]);

    // Walk the panes a reader walks: the issue detail, then the patch list,
    // then the patch detail with its revisions, comments and diff.
    let out = repo.run_dashboard_driven(120, 45, &[], |dash| {
        // The event loop draws once per key it reads, so every keystroke gets
        // a frame of its own and none of these needs a pause behind it. The
        // last pane is the one to wait for: quitting before the patch detail
        // is drawn would narrow what the `+00:00` check below covers.
        dash.send("\t");
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        dash.wait_for(&expected);
        dash.send("q");
    });
    let text = screen_text(&out.stdout);

    assert!(
        text.contains(&expected),
        "expected the date rendered as `{} {}`:\n{}",
        &stored[..10],
        &stored[11..16],
        text
    );
    assert!(
        !text.contains("+00:00"),
        "a stored timestamp reached the screen in full:\n{}",
        text
    );
}

// ── Writing from the review surface (094b055b) ──────────────────────────────

/// The whole point of the issue: read a patch and say something about it
/// without quitting. The body arrives through `$EDITOR`.
#[test]
fn r_records_a_review_composed_in_the_editor() {
    let (repo, id) = repo_with_patch();
    let editor = editor_writing(&repo, "review-editor.sh", "this reads well");

    repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |dash| {
        // Switch to the patch list, open the patch, review, approve.
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        // `R` opens the verdict prompt and `a` answers it. A pty hands
        // keystrokes to the program in the order they were written, so
        // nothing has to pause between the two.
        dash.send("R");
        dash.press("a", "Review recorded");
        dash.send("q");
    });

    let shown = repo.run_ok(&["patch", "show", &id]);
    assert!(
        shown.contains("this reads well") && shown.contains("approve"),
        "no review recorded:\n{}",
        shown
    );
}

/// `c` on a line of the diff anchors the comment there, through the same
/// `--at file:line` path the CLI uses — so the anchor is checked against the
/// revision the comment names, and lands as an inline comment rather than a
/// thread one.
#[test]
fn c_on_a_diff_line_records_an_inline_comment() {
    let (repo, id) = repo_with_patch();
    let editor = editor_writing(&repo, "comment-editor.sh", "why the rename?");

    repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        // Walk down to a line of the diff body. The detail pane opens with the
        // cursor at the top, and the header, revision list and diff header sit
        // above the first anchorable line.
        dash.send("jjjjjjjjjjjjjjjj");
        dash.press("c", "Comment added on");
        dash.send("q");
    });

    let shown = repo.run_ok(&["patch", "show", &id]);
    assert!(
        shown.contains("why the rename?"),
        "no comment recorded:\n{}",
        shown
    );
    assert!(
        shown.contains("src/lib.rs:"),
        "the comment did not land on a line of the file:\n{}",
        shown
    );
}

/// A body that is empty once the `#` context is stripped aborts the write, the
/// way `git commit` does, and nothing is appended.
#[test]
fn a_comment_only_body_aborts_and_records_nothing() {
    let (repo, id) = repo_with_patch();
    // An editor that leaves the seeded `#` lines exactly as they were.
    let editor = repo.write_script("noop-editor.sh", "#!/bin/sh\nexit 0\n");

    let out = repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        // The editor runs and returns; the dashboard saying so is what marks
        // the round trip finished, and it is the same sentence this test is
        // about. Waiting for it is not the assertion — if it never comes, the
        // assertion below fails with the screen attached.
        dash.press("c", "Aborting");
        dash.send("q");
    });

    let shown = repo.run_ok(&["patch", "show", &id]);
    assert!(
        !shown.contains("--- Comments ---") && !shown.contains("--- Inline Comments ---"),
        "an aborted comment was recorded anyway:\n{}",
        shown
    );
    // ...and the reader is told, rather than left wondering whether the key
    // did anything at all.
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("Aborting"),
        "nothing on screen said the comment was abandoned:\n{}",
        text
    );
}

/// An editor that dies mid-write must leave the DAG alone and the dashboard
/// usable — the screen is restored and the next key still works.
#[test]
fn an_editor_killed_mid_write_records_nothing() {
    let (repo, id) = repo_with_patch();
    let editor = repo.write_script(
        "dying-editor.sh",
        "#!/bin/sh\nprintf 'half a thought' > \"$1\"\nkill -TERM $$\n",
    );

    let out = repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        dash.press("c", "Editor exited");
        dash.send("q");
    });

    let shown = repo.run_ok(&["patch", "show", &id]);
    assert!(
        !shown.contains("half a thought"),
        "a killed editor's buffer was recorded:\n{}",
        shown
    );
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("Editor exited"),
        "the dashboard did not say the editor died:\n{}",
        text
    );
    // The screen came back, or `q` would never have been read and the harness
    // would have had to kill the process.
    assert!(
        out.status.success(),
        "the dashboard did not exit cleanly after a killed editor"
    );
}

/// `x` claims the comment under the cursor was answered, and `x` again
/// withdraws the claim. Both go through `PatchState::resolve_comment`, which
/// is why the id on screen is the one the CLI takes.
#[test]
fn x_resolves_and_reopens_the_comment_under_the_cursor() {
    let (repo, id) = repo_with_patch();
    repo.run_ok(&[
        "patch",
        "comment",
        &id,
        "--at",
        "src/lib.rs:2",
        "-b",
        "this line worries me",
    ]);

    // The detail pane opens with the cursor on row 0. Above the inline comment
    // sit six header rows, a blank, the Revisions heading, one revision, a
    // blank and the Inline Comments heading — eleven rows.
    repo.run_dashboard_driven(100, 40, &[], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        dash.send("jjjjjjjjjjj");
        dash.press("x", "resolved");
        dash.send("q");
    });

    let shown = repo.run_ok(&["patch", "show", &id]);
    assert!(
        shown.contains("--- Resolved"),
        "`x` did not mark the comment answered:\n{}",
        shown
    );

    // The same key withdraws the claim. The row moved into the Resolved
    // section, one line further down.
    repo.run_dashboard_driven(100, 40, &[], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        dash.send("jjjjjjjjjjj");
        dash.press("x", "reopened");
        dash.send("q");
    });

    let shown = repo.run_ok(&["patch", "show", &id]);
    assert!(
        !shown.contains("--- Resolved"),
        "`x` did not withdraw the claim:\n{}",
        shown
    );
}

/// `a` shows the change that answered the comment under the cursor: the
/// correspondence between feedback and the revision answering it, which is the
/// thing this project exists to keep. The view is the one `--answers` already
/// built; the dashboard only points it at the selected comment.
#[test]
fn a_shows_what_answered_the_comment_under_the_cursor() {
    let (repo, id) = repo_with_patch();
    repo.run_ok(&[
        "patch",
        "comment",
        &id,
        "--at",
        "src/lib.rs:2",
        "-b",
        "this line worries me",
    ]);
    // A revision that answers it, then the claim that it did.
    repo.git(&["checkout", "feature/x"]);
    repo.commit_file(
        "src/lib.rs",
        "one\ntwo reworded\nthree\nfour\nfive\n",
        "address the comment",
    );
    repo.run_ok(&["patch", "revise", &id, "-b", "reworded"]);
    repo.git(&["checkout", "main"]);
    let shown = repo.run_ok(&["patch", "show", &id]);
    let comment_id = shown
        .lines()
        .find(|l| l.contains("src/lib.rs:2"))
        .and_then(|l| l.split('[').nth(1))
        .and_then(|s| s.split(']').next())
        .expect("patch show prints the comment id in brackets")
        .to_string();
    repo.run_ok(&["patch", "resolve", &id, &comment_id]);

    let out = repo.run_dashboard_driven(100, 40, &[], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        // Six header rows, blank, Revisions heading, two revisions, blank,
        // the Resolved-comment heading — twelve rows down to the comment.
        dash.send("jjjjjjjjjjjj");
        dash.press("a", "answered [");
        dash.send("q");
    });

    // Matched without the interior spaces a redraw is free to skip: ratatui
    // repaints only the cells that changed, so a blank between two words may
    // never be written at all.
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("answered [") && text.contains("two reworded"),
        "`a` did not show the change that answered the comment:\n{}",
        text
    );
}

/// `o` checks out the latest revision's commit, which a revision ref pins.
/// The branch the patch was authored on is deleted here on purpose: that is
/// the state every agent-produced patch is in, and checking `PatchState.branch`
/// out is what used to fail on them.
#[test]
fn o_checks_out_the_revision_even_with_the_branch_gone() {
    let (repo, id) = repo_with_patch();
    let head = repo.git(&["rev-parse", "feature/x"]).trim().to_string();
    repo.git(&["branch", "-D", "feature/x"]);

    let out = repo.run_dashboard_driven(100, 40, &[], |dash| {
        dash.press("P", "Patches (open)");
        // `o` leaves the dashboard for good, so the checkout report it prints
        // on the way out is both the acknowledgement and the exit.
        dash.press("o", "Checked out patch");
    });

    let at = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
    assert_eq!(
        at,
        head,
        "not on the latest revision's commit:\n{}",
        screen_text(&out.stdout)
    );
    let branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]);
    assert!(
        branch.trim().starts_with("collab/"),
        "expected a collab/ branch for patch {}, got {}",
        id,
        branch
    );
}

/// No editor configured is a message naming the alternatives, never a hang and
/// never a silent no-op.
#[test]
fn no_editor_configured_says_so() {
    let (repo, _id) = repo_with_patch();

    let out = repo.run_dashboard_driven(100, 40, &[("EDITOR", ""), ("VISUAL", "")], |dash| {
        dash.press("P", "Patches (open)");
        dash.press("\r", "--- Revisions ---");
        dash.press("c", "EDITOR");
        dash.send("q");
    });
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("$EDITOR") || text.contains("EDITOR"),
        "no message naming the editor variables:\n{}",
        text
    );
    assert!(
        out.status.success(),
        "the dashboard hung or died with no editor configured"
    );
}

/// A captured screen with its spacing removed.
///
/// ratatui redraws only the cells that changed and skips the rest with a
/// cursor move, which `screen_text` strips — so a run of spaces between two
/// words can vanish from the capture while both words are genuinely on screen.
/// Comparing without spaces asks what these tests actually mean: is this text
/// on the screen, in this order.
fn squeezed(bytes: &[u8]) -> String {
    screen_text(bytes).replace(' ', "")
}

// ── The issue side of the review loop (d7158619 / 02d3eb34) ─────────────────

/// The report that opened `d7158619`: a reader opened the dashboard on an
/// issue and could not answer it. The CLI could — `git-collab issue comment
/// <id> -b …` — so this is the binding and the target resolution, not the
/// write, and it goes through the same `$EDITOR` suspend the patch side uses.
#[test]
fn c_comments_on_the_issue_under_the_cursor() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    let id = repo.issue_open("something worth answering");
    let editor = editor_writing(&repo, "issue-comment.sh", "answering it from the dashboard");

    repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| {
        dash.press("c", "Comment added on issue");
        dash.send("q");
    });

    let shown = repo.run_ok(&["issue", "show", &id]);
    assert!(
        shown.contains("answering it from the dashboard"),
        "the editor's body did not reach the issue:\n{}",
        shown
    );
}

/// And from the list pane, where the old `pane == Pane::Detail` guard sent `c`
/// into silence. The dashboard opens focused on the list, so this is the press
/// the reporter actually made.
#[test]
fn c_comments_from_the_list_pane_where_it_used_to_do_nothing() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    let id = repo.issue_open("pressed from the list");
    let editor = editor_writing(&repo, "list-comment.sh", "no tab press was needed");

    repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| {
        // No `Tab`: focus is on the list, which is where `c` used to be inert.
        dash.press("c", "Comment added on issue");
        dash.send("q");
    });

    let shown = repo.run_ok(&["issue", "show", &id]);
    assert!(
        shown.contains("no tab press was needed"),
        "`c` on the list pane recorded nothing:\n{}",
        shown
    );
}

/// The other half of the collision: the Event History is still reachable, on
/// `e`.
#[test]
fn e_opens_the_event_history() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    repo.issue_open("has a history");

    let out = repo.run_dashboard_driven(100, 30, &[], |dash| {
        // The pane's rows rather than its title: see `squeezed`.
        dash.press("e", "Issue Open |");
        dash.send("q");
    });
    assert!(
        squeezed(&out.stdout).contains("EventHistory"),
        "`e` did not open the Event History:\n{}",
        screen_text(&out.stdout)
    );
}

/// An empty buffer abandons an issue comment, the way it does everywhere else
/// this program asks for prose, and the reader is told.
#[test]
fn an_empty_issue_comment_aborts_and_records_nothing() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    let id = repo.issue_open("left alone");
    let editor = repo.write_script("noop.sh", "#!/bin/sh\nexit 0\n");

    let out = repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| {
        dash.press("c", "Aborting");
        dash.send("q");
    });

    let shown = repo.run_ok(&["issue", "show", &id]);
    assert!(
        !shown.contains("--- Comments ---"),
        "an aborted comment was recorded anyway:\n{}",
        shown
    );
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("Aborting"),
        "nothing said the comment was abandoned:\n{}",
        text
    );
}

/// Triage, decided rather than deferred again: `C` closes the issue under the
/// cursor with a reason composed in `$EDITOR`.
#[test]
fn shift_c_closes_the_issue_with_a_reason() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    let id = repo.issue_open("done with this one");
    let editor = editor_writing(&repo, "close-reason.sh", "fixed by the patch above");

    repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| {
        dash.press("C", "closed");
        dash.send("q");
    });

    let shown = repo.run_ok(&["issue", "show", &id]);
    assert!(
        shown.contains("[closed]"),
        "the issue is not closed:\n{}",
        shown
    );
    assert!(
        shown.contains("fixed by the patch above"),
        "the reason did not reach the event:\n{}",
        shown
    );
}

/// The `a` filter offers "closed" and "all", so it has to be able to show a
/// closed issue.
///
/// It could not: closing archives the ref, and the dashboard loaded issues
/// with `state::list_issues`, which excludes archived ones. So `a` cycled
/// through two settings that could only ever produce an empty list — a filter
/// naming a state it was structurally unable to reach. `issue list -a` and the
/// web UI both use the `_with_archived` listers for exactly this reason; the
/// dashboard now does too. Found by trying to reopen from the dashboard.
#[test]
fn the_status_filter_can_reach_a_closed_issue() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    // One word: a title is drawn into cells that were blank, so it survives
    // the capture whole, while a needle like `(all)` cannot — the `(` of
    // `Issues (open)` does not change when the word after it does, so it is
    // never re-emitted and never appears in the stream.
    let id = repo.issue_open("shut-but-not-gone");
    repo.issue_close(&id);

    let out = repo.run_dashboard_driven(100, 30, &[], |dash| {
        dash.press("a", "shut-but-not-gone");
        dash.send("q");
    });
    assert!(
        squeezed(&out.stdout).contains("shut-but-not-gone"),
        "the closed issue is unreachable from the dashboard:\n{}",
        screen_text(&out.stdout)
    );
}

/// The same key the other way. Reopening asks for nothing, because it records
/// no reason and because it is itself the undo of a close.
#[test]
fn shift_c_reopens_a_closed_issue() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    let id = repo.issue_open("closed too early");
    repo.issue_close(&id);

    repo.run_dashboard_driven(100, 30, &[], |dash| {
        // Closed issues are hidden by the default filter, so show them first.
        // No wait between the two: a pty hands keystrokes to the program in
        // the order they were written, and the wait that matters is the one
        // on the write `C` makes.
        dash.send("a");
        dash.press("C", "reopened");
        dash.send("q");
    });

    let shown = repo.run_ok(&["issue", "show", &id]);
    assert!(
        shown.contains("[open]"),
        "the issue did not reopen:\n{}",
        shown
    );
}

// ── The bindings, on a real screen (02d3eb34) ───────────────────────────────

/// A key this pane does not bind says so. The failure this replaces is not
/// that the key did nothing; it is that the reader could not tell "nothing is
/// bound here" from "the binding is broken".
#[test]
fn an_unbound_key_says_so_on_screen() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    repo.issue_open("look at me");

    let out = repo.run_dashboard_driven(100, 30, &[], |dash| {
        dash.press("z", "does nothing");
        dash.send("q");
    });
    let text = screen_text(&out.stdout);
    assert!(
        text.contains("does nothing") && text.contains('?'),
        "an unbound key left no trace on screen:\n{}",
        text
    );
}

/// `?` lists this pane's keys. The footer is one line and this surface has
/// long since outgrown it — `o`, `w`, `Ctrl-E` and the rest have never been
/// advertised anywhere.
#[test]
fn question_mark_lists_this_panes_keys() {
    let repo = TestRepo::new("Reader", "reader@example.com");
    repo.issue_open("look at me");

    let out = repo.run_dashboard_driven(100, 30, &[], |dash| {
        dash.press("?", "Keys");
        dash.send("q");
    });
    let text = squeezed(&out.stdout);
    assert!(
        text.contains("Keys"),
        "no key overlay appeared:\n{}",
        screen_text(&out.stdout)
    );
    assert!(
        text.contains("checkoutthelinkedpatch"),
        "the overlay does not list the keys the footer cannot fit:\n{}",
        screen_text(&out.stdout)
    );
}