ghtui 0.2.0

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! Home.

use super::*;
use crate::route::{app, code, search};

/// `]` and `[` go from section to section, either way: back from a
/// section's first row is the section before, not the one it's in.
#[test]
fn sections_go_both_ways() {
    let mut state = with_prs(2);
    crate::fixtures::section(&mut state, 0, mine(2, 2));
    press(&mut state, "<Home>");
    let first = page(&state).selected;
    press(&mut state, "]");
    assert!(page(&state).selected > first, "{:?}", page(&state).selected);
    press(&mut state, "[");
    assert_eq!(page(&state).selected, first);
}

#[test]
fn home_selects_the_first_row_and_opens_it() {
    let mut state = with_prs(3);
    assert_eq!(route(&state), Route::App(app::Home));
    assert!(
        selected_text(&state).contains("PR 1"),
        "a fresh page selects its first row"
    );
    press(&mut state, "<Down>");
    assert!(selected_text(&state).contains("PR 2"));
    press(&mut state, "<Up>");
    let cmds = fetches(press(&mut state, "<Enter>"));
    let pr = PrRef::parse("o/r#1").unwrap();
    assert_eq!(
        cmds,
        vec![
            crate::fixtures::fetch_pr(&pr),
            fetch(DataKey::InProjects(pr.repo.clone(), pr.number)),
            fetch(DataKey::PrActivity(pr.clone()))
        ]
    );
    assert_eq!(route(&state), Route::pr(pr.clone()));
    assert_eq!(state.busy().as_deref(), Some("Loading"));

    crate::fixtures::pr_fetched(&mut state, &pr, Err(ApiError::Network("down".into())));
    let text = page_text(&state);
    assert!(text.contains("network error: down"), "{text}");
    assert_eq!(
        press(&mut state, "r"),
        vec![crate::fixtures::fetch_pr(&pr)],
        "the activity is still loading"
    );

    press(&mut state, "<BS>");
    assert_eq!(route(&state), Route::App(app::Home));
    assert!(
        selected_text(&state).contains("PR 1"),
        "the selection survives"
    );
    act(&mut state, Action::Forward);
    assert_eq!(route(&state), Route::pr(pr), "forward again");
    // q quits, as :q in the palette does.
    press(&mut state, ":q<Enter>");
    assert!(state.quit, ":q quits");
    state.quit = false;
    press(&mut state, "q");
    assert!(state.quit);
}

/// A section shows its search's first rows; the rest are a row that
/// opens the search's own list in ghtui: the same fetch, so the same
/// order and count.
#[test]
fn a_section_opens_the_rest_of_its_list() {
    let mut state = state();
    crate::fixtures::section(&mut state, 1, mine(30, 31));
    state.home[1].search.as_mut().unwrap().rows = 5;
    state.shown_gen += 1;
    state.sync_page();
    let text = page_text(&state);
    assert!(text.contains("Your pull requests  5 of 31"), "{text:?}");
    press(&mut state, "<End>");
    assert_eq!(selected_text(&state).trim(), "… 26 more");
    let key = mine_key(&state);
    press(&mut state, "<Enter>");
    let DataKey::Search(kind, query) = key.clone() else {
        panic!("{key:?}")
    };
    assert_eq!(route(&state), Route::Search(search::Search { kind, query }));
    assert!(selected_text(&state).contains("PR 1"), "the same list");
    press(&mut state, "<BS>");
    let cmds = press(&mut state, "o");
    let [Cmd::OpenUrl(url)] = cmds.as_slice() else {
        panic!("{cmds:?}")
    };
    assert!(url.contains("author:@me"), "{url}");
}

/// Sections are changed from Home: moved, renamed, removed and
/// brought back, each written to config.toml; Home then shows what the
/// file says.
#[test]
fn home_sections_are_changed_in_the_config() {
    use crate::home::{Edit, EditConfig};
    let mut state = with_prs(2);
    press(&mut state, "<Down>");
    assert!(matches!(state.section_here(), Some((1, _))));
    let job = fixtures::sent::<EditConfig>(&act(&mut state, Action::MoveSectionUp));
    assert_eq!(job.edit, Edit::Move { at: 1, up: true });
    assert_eq!(job.shown, crate::home::defaults());
    assert!(
        act(&mut state, Action::MoveSectionUp).is_empty(),
        "one change at a time"
    );
    let mut moved = crate::home::defaults();
    moved.swap(0, 1);
    fixtures::done(&mut state, job, Ok((moved.clone(), None)));
    assert_eq!(state.home, moved);
    assert!(
        matches!(state.section_here(), Some((0, _))),
        "the selection moved with it"
    );
    let job = fixtures::sent::<EditConfig>(&press(&mut state, "<Delete>"));
    assert_eq!(job.edit, Edit::Remove { at: 0 });
    let (removed, table) = (moved[1..].to_vec(), "[[home]]\ntitle = \"x\"".to_owned());
    fixtures::done(&mut state, job, Ok((removed, Some(table.clone()))));
    let restore = Edit::Restore { at: 0, table };
    let job = fixtures::sent::<EditConfig>(&press(&mut state, "<C-z>"));
    assert_eq!(job.edit, restore);
    // A change the file refused says why, and can be tried again.
    let refused = fixtures::failure("config.toml changed since ghtui read it");
    fixtures::done(&mut state, job, Err(refused));
    warns(&state, "changed since");
    let job = fixtures::sent::<EditConfig>(&press(&mut state, "<C-z>"));
    assert_eq!(job.edit, restore);
}

/// Any list of issues, pull requests or repositories can be saved to
/// Home, under a title you give it.
#[test]
fn a_list_is_saved_to_home() {
    let mut state = state();
    let _ = state.push(Route::Search(search::Search {
        kind: crate::api::browse::SearchKind::Issues,
        query: "repo:o/r is:pr label:bug".into(),
    }));
    let _ = act(&mut state, Action::SaveSection);
    let cmds = press(&mut state, "<C-u>Bugs<Enter>");
    assert!(state.overlay.is_none(), "the prompt closed");
    assert_eq!(
        cmds,
        vec![fixtures::start(crate::home::EditConfig {
            path: "config.toml".into(),
            shown: crate::home::defaults(),
            edit: crate::home::Edit::Add {
                title: "Bugs".into(),
                kind: crate::api::browse::SearchKind::Pulls,
                query: "repo:o/r is:pr label:bug".into(),
            },
            what: "Added “Bugs” to Home".into(),
        })]
    );
    let _ = state.push(Route::user("octocat"));
    let _ = act(&mut state, Action::SaveSection);
    assert!(state.overlay.is_none());
}

/// Home on screen fetches its sections again once they're old, one
/// fetch at a time; a Home in another tab or history doesn't.
#[test]
fn home_stays_fresh_only_on_screen() {
    static NOW: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000);
    let mut state = state();
    state.clock = || NOW.load(std::sync::atomic::Ordering::Relaxed);
    let _ = state.load_visible(false);
    let cmds = timers(&mut state);
    let home = Cmd::Timer(Timer::Home, crate::home::FRESH_SECS * 1000);
    assert!(cmds.contains(&home), "{cmds:?}");
    assert!(!timers(&mut state).contains(&home), "one timer");
    // Still loading: nothing more is asked.
    NOW.store(
        1000 + crate::home::FRESH_SECS,
        std::sync::atomic::Ordering::Relaxed,
    );
    assert!(update(&mut state, Msg::Timer(Timer::Home)).is_empty());
    crate::fixtures::section(&mut state, 1, mine(1, 1));
    NOW.store(
        1000 + 2 * crate::home::FRESH_SECS,
        std::sync::atomic::Ordering::Relaxed,
    );
    let cmds = update(&mut state, Msg::Timer(Timer::Home));
    assert_eq!(cmds.len(), 1, "{cmds:?}");
    assert!(crate::fixtures::asks(&cmds, &mine_key(&state)), "{cmds:?}");
    // Away from Home, its timer does nothing and isn't set again.
    let _ = state.push(Route::user("octocat"));
    crate::fixtures::section(&mut state, 1, mine(1, 1));
    NOW.store(
        1000 + 4 * crate::home::FRESH_SECS,
        std::sync::atomic::Ordering::Relaxed,
    );
    assert!(update(&mut state, Msg::Timer(Timer::Home)).is_empty());
    assert!(!timers(&mut state).contains(&home));
    // Back home, what's old is fetched again.
    let cmds = state.back();
    assert!(crate::fixtures::asks(&cmds, &mine_key(&state)), "{cmds:?}");
}

/// Home's sections from the disk cache are from an earlier session:
/// going back to Home fetches them.
#[test]
fn going_back_home_fetches_cached_sections() {
    let mut state = state();
    let key = mine_key(&state);
    let (value, fetched_at) = (Data::Search(Box::new(mine(1, 1))), 0);
    let cached = Remote::cached(Some(crate::store::Cached { value, fetched_at }));
    state.data.insert(key.clone(), cached);
    let _ = state.start_at(Target::Page(Route::Code(code::Repo(ghtui()))).into());
    let cmds = state.back();
    assert!(crate::fixtures::asks(&cmds, &key), "{cmds:?}");
}