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::*;

/// The keybindings overlay scrolls — one row, one page, one wheel notch — and
/// clamps at both ends (#1441, #1442).
#[test]
fn help_overlay_scrolls_by_row_page_and_wheel() {
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    let (_tmp, mut app) = make_app();
    app.open_help(Mode::Board);
    // The overlay records its geometry while it draws, so it has to draw once
    // before its handlers know how far they can go — which is also what a real
    // session does before it reads a key.
    let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
    terminal.draw(|f| render(&app, f, f.area())).unwrap();
    let metrics = app.help_metrics.get();
    assert!(
        metrics.max_scroll > 0,
        "the test frame is meant to hide rows: {metrics:?}"
    );

    app.handle_key(press(KeyCode::Char('j'))).unwrap();
    assert_eq!(app.help_scroll, 1, "one row down");
    app.handle_key(press(KeyCode::Char('k'))).unwrap();
    assert_eq!(app.help_scroll, 0, "and back");

    app.handle_key(press(KeyCode::PageDown)).unwrap();
    assert_eq!(app.help_scroll, app.help_page(), "one page down");
    app.handle_key(press(KeyCode::PageUp)).unwrap();
    assert_eq!(app.help_scroll, 0, "and back");

    app.handle_mouse(scroll_down(1, 1)).unwrap();
    assert_eq!(app.help_scroll, WHEEL_ROWS, "one wheel notch");
    app.handle_mouse(scroll_up(1, 1)).unwrap();
    assert_eq!(app.help_scroll, 0);

    for _ in 0..50 {
        app.handle_key(press(KeyCode::PageDown)).unwrap();
    }
    assert_eq!(
        app.help_scroll, metrics.max_scroll,
        "the last row is reachable"
    );
    for _ in 0..50 {
        app.handle_key(press(KeyCode::PageUp)).unwrap();
    }
    assert_eq!(app.help_scroll, 0, "and the first row stays the first");
}

/// Scrolling the overlay moves the **rendered** rows, and reopening starts at the
/// top again (#1441).
#[test]
fn help_overlay_scrolls_its_rows_and_reopens_at_the_top() {
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    let (_tmp, mut app) = make_app();
    let entries = crate::ui::commandbar::help_overlay(&app.bindings, app.top_mode);
    assert!(
        entries.len() > 12,
        "the overlay must be longer than the frame"
    );

    let draw = |app: &App| {
        let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
        terminal.draw(|f| render(app, f, f.area())).unwrap();
        let buf = terminal.backend().buffer();
        let mut text = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width {
                text.push_str(buf[(x, y)].symbol());
            }
            text.push('\n');
        }
        text
    };

    app.open_help(Mode::Board);
    let first = draw(&app);
    assert!(
        first.contains(&entries[0].0),
        "the top of the list is on screen: {first}"
    );

    app.handle_key(press(KeyCode::PageDown)).unwrap();
    let second = draw(&app);
    assert_ne!(
        second, first,
        "scrolling a page must change what is drawn, not just a counter"
    );
    let page = app.help_page() as usize;
    assert!(
        second.contains(&entries[page].0),
        "the row one page down is now visible: {second}"
    );
    assert!(
        !second.contains(&entries[0].0),
        "and the first page scrolled away"
    );

    // Reopening resets: a help screen that reappeared halfway down would hide the
    // first bindings from the person who opened it to find them.
    app.close_help();
    app.open_help(Mode::Board);
    assert_eq!(app.help_scroll, 0);
    assert!(draw(&app).contains(&entries[0].0));
}

/// tix #1496: `/` searches the keybindings. The query filters the list as it is
/// typed, in the same fuzzy sense `tix search` uses, and the overlay says how much
/// it is hiding.
#[test]
fn help_overlay_search_filters_the_list() {
    let (_tmp, mut app) = make_app();
    let entries = crate::ui::commandbar::help_overlay(&app.bindings, app.top_mode);
    let first_key = entries[0].0.clone();

    app.open_help(Mode::Board);
    assert!(
        draw_help(&app, 80, 24).contains(&first_key),
        "the unfiltered list starts at the top"
    );

    app.handle_key(press(KeyCode::Char('/'))).unwrap();
    assert!(app.help_searching, "the input line has the keyboard");
    for c in "clone".chars() {
        app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    assert_eq!(app.help_query, "clone");

    let filtered = draw_help(&app, 80, 24);
    assert!(
        filtered.contains("clone issue"),
        "the match is listed: {filtered}"
    );
    assert!(
        filtered.contains("of") && filtered.contains("bindings"),
        "and the summary says how many rows the filter kept: {filtered}"
    );
    assert!(
        !filtered.contains("open detail"),
        "an unrelated binding is filtered out: {filtered}"
    );

    // Enter commits the search: the input closes, the filter stays.
    app.handle_key(press(KeyCode::Enter)).unwrap();
    assert!(!app.help_searching);
    assert_eq!(app.help_query, "clone");
    assert!(draw_help(&app, 80, 24).contains("clone issue"));

    // Backspace while the input is open widens it again.
    app.handle_key(press(KeyCode::Char('/'))).unwrap();
    app.handle_key(press(KeyCode::Backspace)).unwrap();
    app.handle_key(press(KeyCode::Backspace)).unwrap();
    assert_eq!(app.help_query, "clo");
    assert!(
        draw_help(&app, 80, 24).contains("clone issue"),
        "still a match"
    );

    // Esc **inside** the input clears the query and does not close the overlay —
    // the same bargain `/` makes on the board.
    app.handle_key(press(KeyCode::Esc)).unwrap();
    assert!(!app.help_searching);
    assert_eq!(app.help_query, "");
    assert!(
        matches!(app.mode, Mode::Help { .. }),
        "clearing a search is not closing the help"
    );
    assert!(
        draw_help(&app, 80, 24).contains(&first_key),
        "the whole list is back"
    );

    // And Esc outside it closes, as it always did.
    app.handle_key(press(KeyCode::Esc)).unwrap();
    assert!(!matches!(app.mode, Mode::Help { .. }));
}

/// The search is *fuzzy*, not a substring test: `nwiss` finds `new issue`, which is
/// the whole point of asking for a fuzzy search rather than a second `/`.
#[test]
fn help_overlay_search_is_fuzzy() {
    let (_tmp, mut app) = make_app();
    app.open_help(Mode::Board);
    app.handle_key(press(KeyCode::Char('/'))).unwrap();
    for c in "nwiss".chars() {
        app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    let text = draw_help(&app, 80, 24);
    assert!(
        text.contains("new issue"),
        "a non-contiguous query still matches: {text}"
    );
}

/// Reopening the overlay starts from a clean slate: a query left over from last
/// time would hide the binding the reader opened the help to find.
#[test]
fn help_overlay_search_resets_when_reopened() {
    let (_tmp, mut app) = make_app();
    app.open_help(Mode::Board);
    app.handle_key(press(KeyCode::Char('/'))).unwrap();
    app.handle_key(press(KeyCode::Char('x'))).unwrap();
    app.handle_key(press(KeyCode::Enter)).unwrap();
    assert_eq!(app.help_query, "x");

    app.close_help();
    app.open_help(Mode::Board);
    assert!(app.help_query.is_empty(), "the query did not survive");
    assert!(!app.help_searching);
}

/// While the input line is up, the caret sits at the end of what has been typed —
/// the text has to land somewhere visible.
#[test]
fn help_overlay_search_puts_the_caret_on_the_input_line() {
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    let (_tmp, mut app) = make_app();
    app.open_help(Mode::Board);
    app.handle_key(press(KeyCode::Char('/'))).unwrap();
    for c in "clone".chars() {
        app.handle_key(press(KeyCode::Char(c))).unwrap();
    }

    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
    terminal.draw(|f| render(&app, f, f.area())).unwrap();
    let cursor = terminal.backend().cursor_position();

    // Find the row the prompt is drawn on, and the column just past its text: the
    // caret must be exactly there, which is the claim — not a re-derivation of the
    // modal's geometry.
    let buf = terminal.backend().buffer();
    let mut found = None;
    for y in 0..buf.area.height {
        let mut row = String::new();
        for x in 0..buf.area.width {
            row.push_str(buf[(x, y)].symbol());
        }
        // `find` returns a *byte* index and the box borders are three bytes each, so
        // the column is the number of characters before the match, not the offset.
        if let Some(byte) = row.find("/clone") {
            let column = row[..byte].chars().count() as u16 + "/clone".chars().count() as u16;
            found = Some((y, column));
        }
    }
    let (row, column) = found.expect("the prompt is drawn");
    assert_eq!(
        cursor,
        ratatui::layout::Position::new(column, row),
        "the prompt row was: {:?}",
        {
            let mut text = String::new();
            for x in 0..buf.area.width {
                text.push_str(buf[(x, row)].symbol());
            }
            text
        }
    );
}

/// The search line is **free text**: `q` and `?` are characters there, not the keys
/// that close the overlay. They *are* bound to cancel in the list mode, and
/// `App::handle_key` rewrites a bound key to its action's canonical chord before the
/// handler sees it — so without a context of its own, `q` would arrive as `Esc` and
/// no query could contain one.
#[test]
fn help_overlay_search_accepts_the_keys_that_close_the_overlay() {
    let (_tmp, mut app) = make_app();
    app.open_help(Mode::Board);
    app.handle_key(press(KeyCode::Char('/'))).unwrap();
    for c in "quit?".chars() {
        app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    assert_eq!(app.help_query, "quit?");
    assert!(app.help_searching, "still typing");
    assert!(
        matches!(app.mode, Mode::Help { .. }),
        "neither `q` nor `?` closed the overlay"
    );

    // Committing it and pressing `q` in the *list* mode still closes, as before.
    app.handle_key(press(KeyCode::Enter)).unwrap();
    app.handle_key(press(KeyCode::Char('q'))).unwrap();
    assert!(!matches!(app.mode, Mode::Help { .. }));
}

#[test]
fn help_opens_from_board_and_closes() {
    let (_tmp, mut app) = make_app();
    let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
    assert!(
        matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
        "got {:?}",
        app.mode
    );
    let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
    assert!(matches!(app.mode, Mode::Board));
}

#[test]
fn help_from_detail_returns_to_detail() {
    let (tmp, mut app) = make_app();
    let dir = tmp.path().join("issues");
    write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
    let config = app.store.config.clone();
    app.board = BoardState::build(&app.store, config.default_config());
    open_detail(&mut app);
    let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
    assert!(
        matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
        "got {:?}",
        app.mode
    );
    let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
    assert!(matches!(app.mode, Mode::Detail));
}

#[test]
fn help_overlay_click_closes_via_recorded_region() {
    let (_tmp, mut app) = make_app();
    let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
    assert!(matches!(app.mode, Mode::Help { .. }));

    let _ = draw_board(&app, 80, 24);

    // A click inside the overlay's content closes it; the mode-navigation
    // column to its left is its own click target and stays a mode switch.
    let action = app.handle_mouse(down_left(20, 5)).unwrap();
    assert_eq!(action, Action::HelpClose);
    app.close_help();

    assert!(matches!(app.mode, Mode::Board));
}

#[test]
fn help_in_cycle_mode_returns_to_the_cycle_mode() {
    let (_tmp, mut app) = make_cycle_app();
    app.handle_key(press(KeyCode::F(4))).unwrap();
    app.handle_key(press(KeyCode::Char('?'))).unwrap();
    assert!(matches!(app.mode, Mode::Help { .. }));
    app.handle_key(press(KeyCode::Esc)).unwrap();
    assert!(
        matches!(app.mode, Mode::Cycle),
        "closing help returns to the Cycle mode, got {:?}",
        app.mode
    );
}