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::*;
use tempfile::TempDir;

#[test]
fn quit_on_q() {
    let (_tmp, mut app) = make_app();
    let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
    assert!(app.quit);
    assert!(matches!(action, Action::Quit));
}

#[test]
fn quit_on_esc() {
    let (_tmp, mut app) = make_app();
    let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
    assert!(app.quit);
}

/// `y` is `confirm`'s chord everywhere else, so it accepts here too — a key
/// that says yes on every other prompt must not do nothing on this one.
#[test]
fn the_confirm_chord_also_accepts_the_edit_warning() {
    let (_tmp, mut app) = make_app();
    let dir = app.store.issues_dir.clone();
    write_issue_with_custom_key(&dir, "2026-01-01-a.md", "build_id");
    app.after_editor("2026-01-01-a.md");

    let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();

    assert!(matches!(app.mode, Mode::Board));
    assert_eq!(app.store.config.custom_attributes.keys, vec!["build_id"]);
}

/// The declaration is idempotent: re-accepting an already-declared key — and
/// the same key in another case, which `custom_attributes()` folds — adds no
/// second row.
#[test]
fn declaring_the_same_key_again_does_not_duplicate_it() {
    let (tmp, mut app) = make_app();
    let dir = app.store.issues_dir.clone();
    write_issue_with_custom_key(&dir, "2026-01-01-a.md", "build_id");
    app.after_editor("2026-01-01-a.md");
    let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();

    for key in ["build_id", "BUILD_ID"] {
        app.mode = repair_mode("unknown_key: already handled", Some(key));
        let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
    }

    let reloaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
    assert_eq!(
        reloaded.custom_attributes.keys,
        vec!["build_id".to_string()]
    );
}

#[test]
fn key_release_ignored() {
    let (_tmp, mut app) = make_app();
    let key = KeyEvent {
        code: KeyCode::Char('q'),
        modifiers: crossterm::event::KeyModifiers::NONE,
        kind: KeyEventKind::Release,
        state: crossterm::event::KeyEventState::NONE,
    };
    let _ = app.handle_key(key).unwrap();
    assert!(!app.quit);
}

#[test]
fn f4_a_disclosure_is_composed_with_the_confirmation_not_substituted() {
    // Cycle-4 F4. The event loop drains at the top of the loop body, so a
    // disclosure raised by `handle_key` at the bottom overwrote that action's
    // confirmation before any frame drew it. Reproduced with a real keypress:
    // on a `links:`-bearing issue, "Moved #1 to in progress" was assigned and
    // then never rendered.
    //
    // The loop itself needs a real terminal, so it is not covered here; this
    // asserts the composition rule the loop delegates to, and the drain
    // topology is covered end-to-end in `tests/mcp.rs` for `main` and the
    // MCP server.
    let (_tmp, mut app) = make_app();
    app.status_msg = Some("Moved #1 to in progress".to_string());
    app.note_disclosure("legacy-1.md: this write removed the retired `links:` key.".into());
    let status = app.status_msg.as_deref().unwrap();
    assert!(
        status.contains("Moved #1 to in progress"),
        "the confirmation must survive: {status}"
    );
    assert!(
        status.contains("retired `links:` key"),
        "and the disclosure must be shown: {status}"
    );
    // Disclosure first, because the actionable instruction is at the *end* of
    // a ~160-character string and the status area is a single unwrapped row.
    // At 80 columns the tail is what gets cut.
    let disclosure_at = status.find("retired").expect("disclosure is present");
    let confirmation_at = status.find("Moved #1").expect("confirmation is present");
    assert!(
        disclosure_at < confirmation_at,
        "the disclosure leads, so its actionable tail survives truncation: {status}"
    );

    // With no prior message the disclosure is shown on its own.
    let (_tmp2, mut app2) = make_app();
    app2.note_disclosure("alone".into());
    assert_eq!(app2.status_msg.as_deref(), Some("alone"));
}

/// #1529: `run_child_tix` refuses a path that is not a tix repository,
/// naming which of the two it is, before it ever spawns.
#[test]
fn run_child_tix_refuses_a_path_that_is_not_a_repository() {
    let tmp = TempDir::new().unwrap();
    let missing = tmp.path().join("absent");
    let err = run_child_tix(&missing, tmp.path()).unwrap_err().to_string();
    assert!(err.contains("not a directory"), "got {err}");

    let no_config = tmp.path().join("plain");
    std::fs::create_dir_all(&no_config).unwrap();
    let err = run_child_tix(&no_config, tmp.path())
        .unwrap_err()
        .to_string();
    assert!(err.contains("no tix.toml"), "got {err}");
}

#[test]
fn function_keys_switch_top_level_modes() {
    let (_tmp, mut app) = make_shell_app();
    assert_eq!(app.top_mode, TopMode::Board);

    // F1 New Issue, F2 Triage, F3 Board, F4 Cycles, F5 Milestones, F12 Config.
    app.handle_key(press(KeyCode::F(1))).unwrap();
    assert!(
        matches!(app.mode, Mode::Form),
        "F1 opens the New Issue mode's own content, got {:?}",
        app.mode
    );
    assert_eq!(app.top_mode, TopMode::NewIssue);

    app.handle_key(press(KeyCode::F(4))).unwrap();
    assert!(
        matches!(app.mode, Mode::Cycle),
        "F4 opens the Cycles mode, got {:?}",
        app.mode
    );
    assert_eq!(app.top_mode, TopMode::Cycle);

    app.handle_key(press(KeyCode::F(5))).unwrap();
    assert!(
        matches!(app.mode, Mode::Settings),
        "F5 opens the Milestones mode on the borrowed Config surface, got {:?}",
        app.mode
    );
    assert_eq!(app.top_mode, TopMode::Milestones);
    assert!(app.milestone_mode);
    assert_eq!(
        app.settings.as_ref().map(|s| s.section),
        Some(ConfigSection::Milestones)
    );

    app.handle_key(press(KeyCode::F(12))).unwrap();
    assert!(matches!(app.mode, Mode::Settings));
    assert_eq!(app.top_mode, TopMode::Config);
    assert!(!app.milestone_mode, "F12 is Config, not Milestones");

    app.handle_key(press(KeyCode::F(2))).unwrap();
    assert!(
        matches!(app.mode, Mode::TriageInbox),
        "F2 must open the triage inbox, got {:?}",
        app.mode
    );
    assert_eq!(app.top_mode, TopMode::Triage);

    app.handle_key(press(KeyCode::F(3))).unwrap();
    assert!(matches!(app.mode, Mode::Board));
    assert_eq!(app.top_mode, TopMode::Board);
}

#[test]
fn unbound_key_takes_the_f12_sentinel_path_unchanged() {
    let (_tmp, mut app) = make_shell_app();
    // `F9` is the one key still reserved and bound nowhere (#1593 reserves it
    // for the Data page), so the unbound case is exercised with it. It falls
    // through to the sentinel rewrite and does nothing rather than switching
    // modes.
    app.handle_key(press(KeyCode::F(9))).unwrap();
    assert!(matches!(app.mode, Mode::Board));
    assert_eq!(app.top_mode, TopMode::Board);
    // An F-key the shell does not bind resolves to no action in the shell.
    assert_eq!(
        app.bindings.action(
            InputContext::Shell,
            KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
        ),
        None
    );
}

#[test]
fn letter_keys_still_open_each_mode() {
    // The F-keys are additive: the existing letters keep working.
    let (_tmp, mut app) = make_shell_app();
    app.handle_key(press(KeyCode::Char('m'))).unwrap();
    assert!(matches!(app.mode, Mode::PlanningManagement));
    assert_eq!(app.top_mode, TopMode::Cycle);
    app.handle_key(press(KeyCode::Esc)).unwrap();
    app.handle_key(press(KeyCode::Char('t'))).unwrap();
    assert!(matches!(app.mode, Mode::TriageInbox));
    assert_eq!(app.top_mode, TopMode::Triage);
}

/// Activating a branch reaches the same target its own surface does: the
/// board switches, the cycle cursor lands on the named cycle, the template
/// seeds the New Issue form.
#[test]
fn activating_a_branch_reaches_its_target() {
    // Board: switch to the non-active board and show the Board mode.
    let (_tmp, mut app) = make_branchy_app();
    app.activate_branch(&Action::SelectBoard("release".into()))
        .unwrap();
    assert!(matches!(app.mode, Mode::Board));
    assert_eq!(app.active_board_name(), "release");
    assert_eq!(app.top_mode, TopMode::Board);

    // Cycle: enter the mode with the cursor on the named cycle.
    let (_tmp, mut app) = make_cycle_app();
    app.activate_branch(&Action::SelectCycle("Ongoing".into()))
        .unwrap();
    assert!(matches!(app.mode, Mode::Cycle));
    assert_eq!(app.top_mode, TopMode::Cycle);
    assert_eq!(
        app.cycle_state.as_ref().map(|s| s.selected),
        Some(1),
        "the cursor lands on Ongoing"
    );

    // Template: open the New Issue form seeded from the record.
    let (_tmp, mut app) = make_templated_app();
    app.activate_branch(&Action::NewFromTemplate("bug".into()))
        .unwrap();
    assert!(matches!(app.mode, Mode::Form));
    assert_eq!(app.top_mode, TopMode::NewIssue);
    assert_eq!(app.form.as_ref().map(|f| f.template.as_str()), Some("bug"));
}