tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;

#[test]
fn opening_an_issue_with_a_mention_shows_the_notice() {
    let (_tmp, mut app, number) = app_with_mention("blocked on @alice");
    app.open_issue_by_number(number).unwrap();
    assert!(matches!(app.mode, Mode::MentionNotice));
    let view = app.mentions.as_ref().expect("the notice is open");
    assert_eq!(view.rows.len(), 1);
    assert_eq!(view.rows[0].body, "alice");
    assert!(
        view.rows[0].context.contains("@alice"),
        "{:?}",
        view.rows[0].context
    );
    // …and it is drawn.
    let text = frame_text(&app, 100, 20);
    assert!(text.contains("Mentions for you"), "{text}");
    assert!(
        text.contains("@alice") || text.contains("blocked on @alice"),
        "{text}"
    );
}

#[test]
fn an_issue_with_no_mentions_for_the_user_opens_no_notice() {
    let (_tmp, mut app, number) = app_with_mention("cc @someone-else");
    app.open_issue_by_number(number).unwrap();
    assert!(
        matches!(app.mode, Mode::Detail),
        "no notice for a stranger's mention"
    );
    assert!(app.mentions.is_none());
}

#[test]
fn ignoring_a_mention_writes_nothing_and_shows_it_again() {
    let (_tmp, mut app, number) = app_with_mention("blocked on @alice");
    app.open_issue_by_number(number).unwrap();
    app.handle_key(press(KeyCode::Char('i'))).unwrap();
    assert!(
        matches!(app.mode, Mode::Detail),
        "ignore returns to the pane"
    );
    assert!(app.mentions.is_none());
    // Nothing was written: the mention is still unread…
    recount_mentions(&mut app);
    assert_eq!(app.unread_mentions, 1);
    // …so the next open shows it again.
    app.open_issue_by_number(number).unwrap();
    assert!(matches!(app.mode, Mode::MentionNotice));
}

#[test]
fn marking_a_mention_read_persists_and_is_not_shown_again() {
    let (_tmp, mut app, number) = app_with_mention("blocked on @alice");
    app.open_issue_by_number(number).unwrap();
    app.handle_key(press(KeyCode::Char('r'))).unwrap();
    assert!(
        app.mentions.is_none(),
        "the last pending mention closes the notice"
    );
    assert!(matches!(app.mode, Mode::Detail));
    assert_eq!(app.unread_mentions, 0, "the count is recomputed");
    app.open_issue_by_number(number).unwrap();
    assert!(
        matches!(app.mode, Mode::Detail),
        "a read mention is not shown again"
    );
}

#[test]
fn jumping_to_a_mention_scrolls_it_to_the_top_and_marks_the_target() {
    // Paragraphs separated by blank lines keep the mention on its own rendered line,
    // and enough of them put it below the first screen so the jump has to scroll.
    let filler = (1..=12)
        .map(|n| format!("paragraph {n}"))
        .collect::<Vec<_>>();
    let body = format!("{}\n\nblocked on @alice\n", filler.join("\n\n"));
    let (_tmp, mut app, number) = app_with_mention(&body);
    app.open_issue_by_number(number).unwrap();
    app.handle_key(press(KeyCode::Enter)).unwrap();
    assert!(app.mentions.is_none(), "the notice closes on jump");

    let detail = app.detail.as_ref().unwrap();
    assert_eq!(
        detail.mention_body.as_deref(),
        Some("alice"),
        "the target is marked for the renderer"
    );
    assert!(
        detail.focus_pending.get(),
        "the jump defers the scroll to the renderer"
    );

    // One draw applies the focus: the pane scrolls the mention paragraph to the top
    // and consumes the pending flag.
    let text = frame_text(&app, 100, 24);
    let detail = app.detail.as_ref().unwrap();
    assert!(!detail.focus_pending.get(), "the draw consumes the focus");
    assert!(
        detail.scroll_offset.get() > 0,
        "scrolled down to the mention: {text}"
    );
    assert!(
        text.contains("blocked on @alice"),
        "the mention is on screen: {text}"
    );
}

#[test]
fn the_indicator_row_appears_with_unread_mentions_and_goes_when_read() {
    let (_tmp, mut app, number) = app_with_mention("blocked on @alice");
    assert_eq!(mention_row_height(&app), 1);
    let text = frame_text(&app, 100, 20);
    assert!(text.contains("1 unread mention"), "{text}");
    assert!(text.contains("press i to open"), "{text}");

    // Mark it read from the view, and the row is gone.
    app.handle_key(press(KeyCode::Char('i'))).unwrap();
    assert!(matches!(app.mode, Mode::Mentions));
    app.handle_key(press(KeyCode::Char('r'))).unwrap();
    assert_eq!(app.unread_mentions, 0);
    assert_eq!(mention_row_height(&app), 0);
    let text = frame_text(&app, 100, 20);
    assert!(!text.contains("unread mention"), "{text}");
    let _ = number;
}

#[test]
fn no_unread_mentions_means_no_indicator_row() {
    let (_tmp, app, _number) = app_with_mention("nothing addressed here");
    assert_eq!(mention_row_height(&app), 0);
    let text = frame_text(&app, 100, 20);
    assert!(!text.contains("unread mention"), "{text}");
}

#[test]
fn the_board_chord_opens_the_mentions_view_across_issues() {
    let (_tmp, mut app) = make_app();
    let first = seed_issue(&mut app, "blocked on @alice");
    let _second = seed_issue(&mut app, "also blocked on @alice");
    app.mention_candidates = vec!["alice".to_string()];
    recount_mentions(&mut app);
    assert_eq!(app.unread_mentions, 2);

    app.handle_key(press(KeyCode::Char('i'))).unwrap();
    assert!(
        matches!(app.mode, Mode::Mentions),
        "the chord opens the view"
    );
    let rows = app
        .mentions_view
        .as_ref()
        .expect("the view is open")
        .rows
        .len();
    assert_eq!(rows, 2, "both issues are listed");
    let text = frame_text(&app, 100, 20);
    assert!(text.contains("Pending mentions"), "{text}");

    // Selecting a row opens that issue at its mention.
    app.handle_key(press(KeyCode::Enter)).unwrap();
    assert!(matches!(app.mode, Mode::Detail));
    assert!(
        app.mentions.is_none(),
        "the opened issue's own notice does not preempt"
    );
    assert_eq!(app.detail.as_ref().unwrap().issue.number, first);
}

#[test]
fn the_mentions_view_reports_nothing_to_show_rather_than_opening_empty() {
    let (_tmp, mut app, _number) = app_with_mention("nothing addressed here");
    app.handle_key(press(KeyCode::Char('i'))).unwrap();
    assert!(matches!(app.mode, Mode::Board));
    assert!(app.mentions_view.is_none());
    assert_eq!(app.status_msg.as_deref(), Some("no pending mentions"));
}

#[test]
fn mentions_disabled_means_no_notice_no_count_and_no_indicator() {
    let (_tmp, mut app, number) = app_with_mention("blocked on @alice");
    assert_eq!(app.unread_mentions, 1, "the mention is there to begin with");
    app.store.config.mentions.enabled = false;
    app.mention_candidates = vec!["alice".to_string()];
    recount_mentions(&mut app);
    assert_eq!(app.unread_mentions, 0);
    assert_eq!(mention_row_height(&app), 0);
    app.open_issue_by_number(number).unwrap();
    assert!(
        matches!(app.mode, Mode::Detail),
        "no notice when the feature is off"
    );
    // The chord says why there is nothing, rather than implying an empty queue. It is
    // a **board** chord, so the test has to be on the board — the detail pane binds `i`
    // to nothing.
    app.mode = Mode::Board;
    app.handle_key(press(KeyCode::Char('i'))).unwrap();
    assert!(matches!(app.mode, Mode::Board));
    assert!(
        app.status_msg
            .as_deref()
            .is_some_and(|m| m.contains("disabled")),
        "{:?}",
        app.status_msg
    );
}

#[test]
fn a_new_mention_opens_the_view_from_the_board() {
    let (_tmp, mut app) = make_app();
    let _first = seed_issue(&mut app, "blocked on @alice");
    app.mention_candidates = vec!["alice".to_string()];
    recount_mentions(&mut app);
    assert_eq!(app.unread_mentions, 1);

    // A new mention lands in the store (the create seam reconciles it).
    let _second = seed_issue(&mut app, "also blocked on @alice");
    app.refresh_mentions_after_change();

    assert!(
        matches!(app.mode, Mode::Mentions),
        "a grown count opens the modal: {:?}",
        app.mode
    );
    assert_eq!(app.mentions_view.as_ref().unwrap().rows.len(), 2);
}

#[test]
fn a_new_mention_refreshes_an_already_open_view() {
    let (_tmp, mut app) = make_app();
    let _first = seed_issue(&mut app, "blocked on @alice");
    let _second = seed_issue(&mut app, "also blocked on @alice");
    app.mention_candidates = vec!["alice".to_string()];
    recount_mentions(&mut app);
    assert_eq!(app.unread_mentions, 2);

    app.handle_key(press(KeyCode::Char('i'))).unwrap();
    assert!(matches!(app.mode, Mode::Mentions));
    app.handle_key(press(KeyCode::Char('j'))).unwrap(); // onto the second row
    assert_eq!(app.mentions_view.as_ref().unwrap().selected, 1);

    let _third = seed_issue(&mut app, "ping @alice");
    app.refresh_mentions_after_change();

    assert!(matches!(app.mode, Mode::Mentions));
    let view = app.mentions_view.as_ref().unwrap();
    assert_eq!(view.rows.len(), 3, "the new row is folded in");
    assert_eq!(view.selected, 1, "the cursor survives the refresh");
}

#[test]
fn a_new_mention_does_not_interrupt_a_form() {
    let (_tmp, mut app) = make_app();
    let _first = seed_issue(&mut app, "blocked on @alice");
    app.mention_candidates = vec!["alice".to_string()];
    recount_mentions(&mut app);
    assert_eq!(app.unread_mentions, 1);

    app.handle_key(press(KeyCode::Char('n'))).unwrap();
    assert!(matches!(app.mode, Mode::Form), "the create form is open");

    let _second = seed_issue(&mut app, "also blocked on @alice");
    app.refresh_mentions_after_change();

    assert!(
        matches!(app.mode, Mode::Form),
        "a form is not interrupted: {:?}",
        app.mode
    );
    assert!(app.mentions_view.is_none());
}