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 detail_esc_back_to_board() {
    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::Esc)).unwrap();
    assert!(matches!(app.mode, Mode::Board));
    assert!(app.detail.is_none());
}

/// The pane's page keys move a **page**, not a handful of lines, and stop at the
/// end of the content instead of scrolling past it (#1442).
#[test]
fn detail_page_keys_scroll_one_page_and_stop_at_the_end() {
    let (_tmp, mut app) = make_mouse_app();
    let (x, y) = card_cell(&app, 0, 0);
    app.handle_mouse(down_left(x, y)).unwrap();
    app.handle_mouse(up_left(x, y)).unwrap();
    app.handle_mouse(down_left(x, y)).unwrap();
    assert!(matches!(app.mode, Mode::Detail));

    if let Some(d) = app.detail.as_mut() {
        d.viewport.set(20);
        d.max_scroll.set(45);
    }
    // A page keeps one row of the previous page, so the reader has an anchor.
    let page = app.detail.as_ref().unwrap().page();
    assert_eq!(page, 19);

    app.handle_key(press(KeyCode::PageDown)).unwrap();
    assert_eq!(app.detail.as_ref().unwrap().scroll_offset.get(), 19);
    app.handle_key(press(KeyCode::PageDown)).unwrap();
    assert_eq!(app.detail.as_ref().unwrap().scroll_offset.get(), 38);
    // 38 + 19 = 57 > 45, so this one lands on the last page rather than past it.
    app.handle_key(press(KeyCode::PageDown)).unwrap();
    assert_eq!(app.detail.as_ref().unwrap().scroll_offset.get(), 45);
    app.handle_key(press(KeyCode::PageUp)).unwrap();
    assert_eq!(app.detail.as_ref().unwrap().scroll_offset.get(), 26);
}

/// `j`/`k` and the arrows scroll the body one row at a time; `g`/`G` jump to the
/// ends. These keys were freed for scrolling when the relationship cursor moved to
/// `Tab`/`BackTab`.
#[test]
fn detail_body_scrolls_by_row_and_jumps_to_the_ends() {
    let (_tmp, mut app) = make_mouse_app();
    let (x, y) = card_cell(&app, 0, 0);
    app.handle_mouse(down_left(x, y)).unwrap();
    app.handle_mouse(up_left(x, y)).unwrap();
    app.handle_mouse(down_left(x, y)).unwrap();
    assert!(matches!(app.mode, Mode::Detail));

    if let Some(d) = app.detail.as_mut() {
        d.viewport.set(20);
        d.max_scroll.set(45);
    }
    let offset = |app: &App| app.detail.as_ref().unwrap().scroll_offset.get();

    app.handle_key(press(KeyCode::Char('j'))).unwrap();
    assert_eq!(offset(&app), 1, "j scrolls down a row");
    app.handle_key(press(KeyCode::Down)).unwrap();
    assert_eq!(offset(&app), 2, "Down scrolls down a row");
    app.handle_key(press(KeyCode::Char('k'))).unwrap();
    assert_eq!(offset(&app), 1, "k scrolls up a row");
    app.handle_key(press(KeyCode::Up)).unwrap();
    assert_eq!(offset(&app), 0, "Up scrolls up a row");
    app.handle_key(press(KeyCode::Char('k'))).unwrap();
    assert_eq!(offset(&app), 0, "and clamps at the top");

    app.handle_key(press(KeyCode::Char('G'))).unwrap();
    assert_eq!(offset(&app), 45, "G jumps to the bottom");
    app.handle_key(press(KeyCode::Char('g'))).unwrap();
    assert_eq!(offset(&app), 0, "g jumps back to the top");
}

#[test]
fn resolve_render_size_falls_back_for_non_tty() {
    assert_eq!(
        resolve_render_size(None),
        (RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT)
    );
    assert_eq!(resolve_render_size(Some((0, 0))), (1, 1));
}

#[test]
fn navigation_toggle_cycles_display_states() {
    let (_tmp, mut app) = make_app();
    assert_eq!(app.nav_display, NavDisplay::Default);
    let toggle = |app: &mut App| {
        app.handle_key(press_with_modifiers(
            KeyCode::Char('n'),
            crossterm::event::KeyModifiers::CONTROL,
        ))
    };
    toggle(&mut app).unwrap();
    assert_eq!(app.nav_display, NavDisplay::Off);
    toggle(&mut app).unwrap();
    assert_eq!(app.nav_display, NavDisplay::Small);
    toggle(&mut app).unwrap();
    assert_eq!(app.nav_display, NavDisplay::Default);
}

#[test]
fn navigation_renders_in_all_three_display_states() {
    let (_tmp, mut app) = make_app();

    app.nav_display = NavDisplay::Default;
    let cells = draw_board(&app, 80, 24);
    assert!(
        flush_line(&cells, 80, 0).starts_with("F1 New Issue"),
        "default navigation shows the full label, got {:?}",
        flush_line(&cells, 80, 0)
    );

    app.nav_display = NavDisplay::Small;
    let cells = draw_board(&app, 80, 24);
    let row = flush_line(&cells, 80, 0);
    assert!(
        row.starts_with("F1 ") && !row.starts_with("F1 New Issue"),
        "small navigation shows only the chord, got {row:?}"
    );

    app.nav_display = NavDisplay::Off;
    let cells = draw_board(&app, 80, 24);
    assert!(
        !flush_line(&cells, 80, 0).starts_with("F1"),
        "off navigation draws nothing in the left column"
    );
}

/// `l` and `→` open the focused row exactly like `Enter`/a click and move the
/// keyboard into the opened content; `Esc` leaves the rail without opening.
#[test]
fn rail_right_and_l_activate_the_focused_row() {
    // `l` on the `release` limb switches to that board and returns.
    let (_tmp, mut app) = make_branchy_app();
    let board_trunk = app
        .nav_rows()
        .iter()
        .position(|action| *action == Action::SwitchMode(TopMode::Board))
        .expect("the Board trunk is a rail row");
    let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
    let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
    let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
    assert_eq!(
        app.nav_rows()[board_trunk + 2],
        Action::SelectBoard("release".into())
    );
    let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
    assert_eq!(app.active_board_name(), "release");
    assert_eq!(
        app.nav_focus_index(),
        None,
        "`l` opens the row and hands the keyboard to the content"
    );

    // `→` runs the same transition (here the already-active `default` board).
    let (_tmp, mut app) = make_branchy_app();
    let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
    let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
    let _ = app.handle_key(press(KeyCode::Right)).unwrap();
    assert_eq!(app.active_board_name(), "default");
    assert_eq!(app.nav_focus_index(), None, "`→` opens the row too");

    // `Esc` leaves the rail without activating anything.
    let (_tmp, mut app) = make_branchy_app();
    let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
    let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
    assert_eq!(app.nav_focus_index(), None);
}

/// Entering the rail with `h`/`Left` lands the cursor on the active mode's
/// trunk; the cursor highlight must outrank the active-mode colour so the
/// position is always visible.
#[test]
fn the_rail_cursor_is_visible_on_the_active_trunk() {
    let (_tmp, mut app) = make_branchy_app();
    app.nav_display = NavDisplay::Default;
    let board_trunk = app
        .nav_rows()
        .iter()
        .position(|action| *action == Action::SwitchMode(TopMode::Board))
        .expect("the Board trunk is a rail row");
    // Board is the active mode and starts at its left edge, so `h` enters the
    // rail on the Board trunk — the row that would otherwise draw active-cyan.
    let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
    assert_eq!(app.nav_focus_index(), Some(board_trunk));
    let buffer = draw_buffer(&app, 80, 24);
    assert_eq!(
        buffer[(0, board_trunk as u16)].style().bg,
        Some(Color::DarkGray),
        "the cursor on the active trunk is highlighted, not left as active-cyan"
    );
}

/// A focused rail draws a right-edge focus accent; the unfocused rail does not.
#[test]
fn the_focused_rail_draws_a_focus_accent() {
    let (_tmp, mut app) = make_branchy_app();
    app.nav_display = NavDisplay::Default;
    let edge = app.nav_width() - 1;
    let unfocused = draw_buffer(&app, 80, 24);
    assert_ne!(
        unfocused[(edge, 0)].style().fg,
        Some(Color::Yellow),
        "the unfocused rail draws no focus accent"
    );
    let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
    let focused = draw_buffer(&app, 80, 24);
    assert_eq!(
        focused[(edge, 0)].style().fg,
        Some(Color::Yellow),
        "the focused rail's right edge is accented"
    );
}

#[test]
fn every_navigable_top_level_page_enters_the_rail_with_h_or_left() {
    let modes = [
        TopMode::Board,
        TopMode::Cycle,
        TopMode::Milestones,
        TopMode::Repos,
        TopMode::Stats,
        TopMode::Schedule,
        TopMode::Portfolio,
        TopMode::Library,
        TopMode::Config,
    ];
    for mode in modes {
        for key in [KeyCode::Char('h'), KeyCode::Left] {
            let (_tmp, mut app) = make_app();
            app.nav_display = NavDisplay::Default;
            app.switch_top_mode(mode).unwrap();
            app.handle_key(press(key)).unwrap();
            assert!(
                app.nav_focus_index().is_some(),
                "{mode:?} did not hand {key:?} to the navigation rail"
            );
        }
    }
}

#[test]
fn the_new_issue_form_keeps_h_as_text_and_uses_left_at_column_zero_for_navigation() {
    let (_tmp, mut app) = make_app();
    app.switch_top_mode(TopMode::NewIssue).unwrap();

    app.handle_key(press(KeyCode::Char('h'))).unwrap();
    assert_eq!(
        app.form.as_ref().unwrap().title,
        "h",
        "a letter typed into the title remains text"
    );
    app.form.as_mut().unwrap().cursor_to_start();
    app.handle_key(press(KeyCode::Left)).unwrap();
    assert!(
        app.nav_focus_index().is_some(),
        "Left at column zero reaches the rail"
    );
}