tix-tracker 0.11.0

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

#[test]
fn parses_and_normalizes_documented_chords() {
    assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
    assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
    assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
    assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
    assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
    assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
    assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
    assert!(KeyChord::parse("ctrl+wat").is_err());
    assert!(KeyChord::parse("f99").is_err());
}

#[test]
fn defaults_validate_and_cross_mode_reuse_is_allowed() {
    let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
    assert!(
        defaults
            .action(
                InputContext::Board,
                KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
            )
            .is_some()
    );
    let mut overrides = KeyBindings::default();
    overrides.board.open = Some(vec!["f4".into()]);
    overrides.detail.close = Some(vec!["f4".into()]);
    assert!(KeyBindings::resolve(&overrides).is_ok());
}

#[test]
fn filter_modal_opens_the_picker_and_commits_on_the_apply_key() {
    let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
    let ctx = InputContext::FilterModal;
    // `enter`, `l` and `right` all open the focused row's picker and rewrite to
    // the action's canonical event, so the handler sees one key rather than
    // three. A regression here would make `enter` commit again (the pre-overhaul
    // behavior) with no handler change to notice it.
    for code in [KeyCode::Enter, KeyCode::Char('l'), KeyCode::Right] {
        let event = KeyEvent::new(code, KeyModifiers::NONE);
        let action = bindings.action(ctx, event);
        assert_eq!(
            action,
            Some(TuiAction::OpenPicker),
            "{code:?} must open the focused row's picker"
        );
        assert_eq!(
            bindings.canonical_event(ctx, TuiAction::OpenPicker),
            KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE),
            "{code:?} must canonicalize to the picker's F2"
        );
    }
    // A plain, always-deliverable key commits. It canonicalizes to `enter` so
    // the handler can tell it apart from the opener — and it is deliberately not
    // `ctrl+enter`, which a terminal without the kitty keyboard protocol sends
    // as the same byte as plain `enter`.
    let commit = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
    assert_eq!(bindings.action(ctx, commit), Some(TuiAction::Submit));
    assert_eq!(
        bindings.canonical_event(ctx, TuiAction::Submit),
        KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
    );
    // The plain `enter` that opens the picker is not the one that commits.
    assert_eq!(
        bindings.action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
        Some(TuiAction::OpenPicker)
    );
    assert!(
        bindings
            .action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
            .is_none(),
        "the unreachable `ctrl+enter` chord must not be bound at all"
    );
}

#[test]
fn board_binds_v_to_views_and_shift_v_to_view_and_detail_keeps_v() {
    let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
    // The board's `v` is the saved-views modal (WP05), so the read-only viewer
    // (#1507) takes the shifted letter there; Detail has no views modal and
    // keeps the plain `v`.
    let board_view = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE);
    assert_eq!(
        bindings.action(InputContext::Board, board_view),
        Some(TuiAction::View),
        "the board must bind `V` to view"
    );
    assert_eq!(
        bindings.canonical_event(InputContext::Board, TuiAction::View),
        board_view,
        "`view` must canonicalize to the board's own chord so the handler sees it as typed"
    );
    assert_eq!(
        bindings.action(
            InputContext::Board,
            KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
        ),
        Some(TuiAction::Views),
        "the board must bind `v` to the saved-views modal"
    );
    let detail_view = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
    assert_eq!(
        bindings.action(InputContext::Detail, detail_view),
        Some(TuiAction::View),
        "Detail must bind `v` to view"
    );
    assert_eq!(
        bindings.canonical_event(InputContext::Detail, TuiAction::View),
        detail_view,
        "`view` must canonicalize to `v` in Detail"
    );
}

/// The conflict prompt's three decisions (#1510): every choice is reachable by
/// two chords, and each pair resolves to an action whose canonical event is the
/// code the handler's arms read (`y`, `Enter`, `r`) — the indirection that makes
/// `Esc` abandon and `c` compare without a chord name in the logic.
#[test]
fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
    let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
    let cases = [
        (KeyCode::Char('o'), TuiAction::Confirm),
        (KeyCode::Char('y'), TuiAction::Confirm),
        (KeyCode::Char('a'), TuiAction::Ignore),
        (KeyCode::Esc, TuiAction::Ignore),
        (KeyCode::Char('c'), TuiAction::Reedit),
        (KeyCode::Char('r'), TuiAction::Reedit),
    ];
    for (code, action) in cases {
        assert_eq!(
            bindings.action(
                InputContext::EditConflict,
                KeyEvent::new(code, KeyModifiers::NONE)
            ),
            Some(action),
            "{code:?} must be {action:?} in the conflict prompt"
        );
    }
    // …and the canonical events are what `handle_edit_conflict_key` matches on,
    // so the mapping is asserted rather than assumed.
    for (action, expected) in [
        (TuiAction::Confirm, KeyCode::Char('y')),
        (TuiAction::Ignore, KeyCode::Enter),
        (TuiAction::Reedit, KeyCode::Char('r')),
    ] {
        assert_eq!(
            bindings.canonical_event(InputContext::EditConflict, action),
            KeyEvent::new(expected, KeyModifiers::NONE),
            "{action:?} must hand the handler `{expected:?}`"
        );
    }
    // A key nobody bound does nothing: `q` must not close the prompt.
    assert_eq!(
        bindings.action(
            InputContext::EditConflict,
            KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
        ),
        None
    );
}

/// The Config mode's column reordering (tui-config-mode). `Shift+K`/
/// `Shift+J` move the selected column; they are distinct from the plain
/// `k`/`j` cursor moves, they accept the documented TOML override, and a
/// conflict inside the same context is still rejected.
#[test]
fn settings_binds_column_moves_distinct_from_the_cursor() {
    let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
    let shifted = |ch: char| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT);
    assert_eq!(
        bindings.action(InputContext::Settings, shifted('K')),
        Some(TuiAction::MoveColumnUp)
    );
    assert_eq!(
        bindings.action(InputContext::Settings, shifted('J')),
        Some(TuiAction::MoveColumnDown)
    );
    // Plain `k`/`j` stay the cursor; only the shifted pair reorders.
    assert_eq!(
        bindings.action(
            InputContext::Settings,
            KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)
        ),
        Some(TuiAction::MoveUp)
    );
    assert_eq!(
        bindings.action(
            InputContext::Settings,
            KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
        ),
        Some(TuiAction::MoveDown)
    );
    // The canonical events are the uppercase codes the handler matches on.
    assert_eq!(
        bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnUp),
        shifted('K')
    );
    assert_eq!(
        bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnDown),
        shifted('J')
    );

    // The documented TOML fields load and take effect.
    let mut overrides = KeyBindings::default();
    overrides.settings.move_column_up = Some(vec!["ctrl+u".into()]);
    overrides.settings.move_column_down = Some(vec!["ctrl+d".into()]);
    let overridden = KeyBindings::resolve(&overrides).unwrap();
    assert_eq!(
        overridden.action(
            InputContext::Settings,
            KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)
        ),
        Some(TuiAction::MoveColumnUp)
    );
    assert_eq!(
        overridden.action(
            InputContext::Settings,
            KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
        ),
        Some(TuiAction::MoveColumnDown)
    );

    // A same-context conflict is still rejected, naming the mode and action.
    let mut conflict = KeyBindings::default();
    conflict.settings.move_column_up = Some(vec!["a".into()]);
    let error = KeyBindings::resolve(&conflict).unwrap_err().to_string();
    assert!(
        error.contains("settings") && error.contains("move_column_up"),
        "the conflict must name the mode and the action: {error}"
    );
}

/// Task 5.4: `i` is bound to the mentions view on the board, resolves without a
/// conflict against every other board default, and canonicalizes back to `i` — so
/// the board's own handler can read the key it advertises.
#[test]
fn the_mentions_view_binds_i_on_the_board_without_a_conflict() {
    let bindings = KeyBindings::resolve(&KeyBindings::default())
        .expect("adding open_mentions must not conflict with an existing board chord");
    let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
    assert_eq!(
        bindings.action(InputContext::Board, key),
        Some(TuiAction::OpenMentions)
    );
    assert_eq!(
        bindings.canonical_event(InputContext::Board, TuiAction::OpenMentions),
        key
    );
    assert_eq!(
        bindings
            .chords(InputContext::Board, TuiAction::OpenMentions)
            .len(),
        1
    );
    // The action is reachable by name from `tix.toml`, like every other one.
    let mut overrides = KeyBindings::default();
    overrides.board.open_mentions = Some(vec!["ctrl+m".into()]);
    let rebound = KeyBindings::resolve(&overrides).expect("a rebind resolves");
    assert_eq!(
        rebound.action(
            InputContext::Board,
            KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL)
        ),
        Some(TuiAction::OpenMentions)
    );
}

#[test]
fn conflicts_report_mode_and_actions() {
    let mut overrides = KeyBindings::default();
    overrides.board.open = Some(vec!["ctrl+n".into()]);
    overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
    let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
    assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
}

#[test]
fn shell_defaults_resolve_mode_switches_and_toggle() {
    let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
    // F1 New Issue, F2 Triage, F3 Board, F4 Cycles, F5 Milestones,
    // F6 Repos (tix #1529), F12 Config.
    let cases = [
        (KeyCode::F(1), TuiAction::SwitchToNewIssue),
        (KeyCode::F(2), TuiAction::SwitchToTriage),
        (KeyCode::F(3), TuiAction::SwitchToBoard),
        (KeyCode::F(4), TuiAction::SwitchToCycle),
        (KeyCode::F(5), TuiAction::SwitchToMilestones),
        (KeyCode::F(6), TuiAction::SwitchToRepos),
        (KeyCode::F(12), TuiAction::SwitchToConfig),
    ];
    for (code, action) in cases {
        assert_eq!(
            bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
            Some(action),
            "{code:?} must switch modes in the shell"
        );
        assert_eq!(
            bindings.canonical_event(InputContext::Shell, action),
            KeyEvent::new(code, KeyModifiers::NONE),
            "{action:?} must canonicalize to its default chord"
        );
    }
    assert_eq!(
        bindings.action(
            InputContext::Shell,
            KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
        ),
        Some(TuiAction::ToggleNavigation)
    );
    // F6 switches to the Repos mode (tix #1529); F8 and F11 are the Schedule
    // and Library pages (tix #1593) — the first of the reserved keys to be
    // spent.
    assert_eq!(
        bindings.action(
            InputContext::Shell,
            KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)
        ),
        Some(TuiAction::SwitchToSchedule)
    );
    assert_eq!(
        bindings.action(
            InputContext::Shell,
            KeyEvent::new(KeyCode::F(11), KeyModifiers::NONE)
        ),
        Some(TuiAction::SwitchToLibrary)
    );
    // WP06: F10 was reserved too, and the Portfolio page's Projects tab took
    // it.
    assert_eq!(
        bindings.action(
            InputContext::Shell,
            KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)
        ),
        Some(TuiAction::SwitchToPortfolio)
    );
    // F9 stays reserved: the shell binds nothing there until its page exists.
    // `#1593` reserves F9 for the Data page (Import/Export, Archive, Duplicates),
    // and this mission's epic surface is the F10 Portfolio page's Epics tab — so
    // F9 is deliberately **not** spent here. F7 left this list when mission
    // `project-issue-health-status-01M3C8HX` gave it the Stats page's Health tab,
    // and F10 left it with the Portfolio page (WP06).
    assert_eq!(
        bindings.action(
            InputContext::Shell,
            KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
        ),
        None,
        "F9 must stay unbound in the shell until the Data page exists"
    );
}

#[test]
fn shell_conflict_reports_both_actions_and_mode() {
    let mut overrides = KeyBindings::default();
    overrides.shell.switch_to_board = Some(vec!["f2".into()]);
    overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
    let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
    assert!(
        error.contains("shell")
            && error.contains("switch_to_board")
            && error.contains("switch_to_triage"),
        "got {error:?}"
    );
}