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)
);
}