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 std::fs;

#[test]
fn filter_picker_options_are_derived_from_project_data() {
    let (_tmp, app) = make_app_with_planning();
    let options = |field: FilterField| app.filter_picker_options(field);
    let ids = |field: FilterField| -> Vec<String> {
        options(field).into_iter().map(|item| item.id).collect()
    };

    // Status: exactly the active board's columns, in board order.
    let columns = app.active_board_config().columns.clone();
    assert_eq!(ids(FilterField::Status), columns);

    // Priority: the built-ins are always offered.
    let priorities = ids(FilterField::Priority);
    for built_in in ["low", "medium", "high"] {
        assert!(
            priorities.contains(&built_in.to_string()),
            "priority list must offer the built-in {built_in}: {priorities:?}"
        );
    }

    // Parent: every stored issue, by canonical reference.
    let parent_id = app
        .store
        .list()
        .unwrap()
        .first()
        .map(crate::id::canonical_ref)
        .unwrap()
        .to_string();
    assert!(
        ids(FilterField::Parent).contains(&parent_id),
        "every stored issue is a candidate parent"
    );

    // Cycle / milestone: the planner's names.
    assert!(ids(FilterField::Cycle).contains(&"Cycle 1".to_string()));
    assert!(ids(FilterField::Milestone).contains(&"GA".to_string()));

    // Category: the project's own configured vocabulary.
    assert_eq!(
        ids(FilterField::Category),
        app.store.config.categories.order
    );
}

/// The prompt chain that the old re-entry broke: leaving a terminal column
/// (reason) then hitting a blocking WIP limit (override reason). The first
/// reason used to be dropped at the WIP prompt, so the transition prompt came
/// back and the move looped.
#[test]
fn terminal_exit_reason_survives_the_wip_override_prompt() {
    let (_tmp, mut app) = make_terminal_blocked_app();
    {
        let board = app
            .store
            .config
            .kanban
            .configurations
            .get_mut("default")
            .unwrap();
        board.wip.limits.insert(
            "todo".into(),
            crate::wip::config::ColumnLimit::items(0, crate::wip::config::LimitPolicy::Block),
        );
    }
    let _ = app.start_move(MoveTarget::Named("todo".into())).unwrap();
    assert!(
        matches!(app.mode, Mode::TransitionReason { .. }),
        "leaving the terminal column asks for a reason, got {:?}",
        app.mode
    );
    for c in "reopened".chars() {
        let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
    assert!(
        matches!(app.mode, Mode::WipOverride { .. }),
        "the blocking limit asks for its own reason next, got {:?}",
        app.mode
    );
    for c in "capacity exception".chars() {
        let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
    assert!(matches!(app.mode, Mode::Board), "the move finishes");

    let issue = app.store.get(IssueSelector::Number(2)).unwrap();
    assert_eq!(issue.status.to_string(), "todo");
    let path = app.store.config.issues_dir.join(&issue.filename);
    let file = fs::read_to_string(path).unwrap();
    assert!(
        file.contains("reopened"),
        "the transition reason survived the WIP prompt:
{file}"
    );
    assert!(
        file.contains("capacity exception"),
        "the WIP override reason was recorded too:
{file}"
    );
}

/// `F2` inside the form keeps its local picker meaning: a context that binds the
/// key wins over the shell (tui-mode-navigation, D2).
#[test]
fn f2_inside_form_opens_the_picker_instead_of_triage() {
    let (_tmp, mut app) = make_shell_app();
    app.handle_key(press(KeyCode::Char('n'))).unwrap();
    assert!(matches!(app.mode, Mode::Form));
    assert_eq!(app.top_mode, TopMode::NewIssue);

    // Focus a field that has a picker (`Title` has none), so opening the picker
    // is observable.
    app.form.as_mut().unwrap().focus = crate::ui::form::FormFocus::Labels;
    app.handle_key(press(KeyCode::F(2))).unwrap();
    assert!(matches!(app.mode, Mode::Form), "the form stays focused");
    assert!(
        app.form_picker.is_some(),
        "F2 must still open the form picker"
    );
    assert_eq!(
        app.top_mode,
        TopMode::NewIssue,
        "F2 must not have switched the shell to Triage"
    );
}

#[test]
fn new_issue_target_triage_creates_into_the_entry_status() {
    let (_tmp, mut app) = make_shell_app();
    app.handle_key(press(KeyCode::F(1))).unwrap();
    type_text(&mut app, "Inbox bound");

    // Walk to the Target row (last three rows of the ring) and flip it.
    for _ in 0..12 {
        app.handle_key(press(KeyCode::Down)).unwrap();
    }
    assert_eq!(
        app.form.as_ref().unwrap().focus,
        crate::ui::form::FormFocus::Target
    );
    app.handle_key(press(KeyCode::Enter)).unwrap();
    let form = app.form.as_ref().unwrap();
    assert_eq!(form.target, crate::ui::form::NewIssueTarget::Triage);
    assert_eq!(
        form.status, "triage",
        "choosing Triage writes the inbox entry status"
    );

    // Off the toggle and onto the body row, so Enter submits rather than
    // flipping the target back.
    app.handle_key(press(KeyCode::Down)).unwrap();
    app.handle_key(press(KeyCode::Down)).unwrap();
    assert_eq!(
        app.form.as_ref().unwrap().focus,
        crate::ui::form::FormFocus::Body
    );
    app.handle_key(press(KeyCode::Enter)).unwrap();

    assert!(matches!(app.mode, Mode::Board));
    let issue = app
        .store
        .list()
        .unwrap()
        .into_iter()
        .find(|i| i.title == "Inbox bound")
        .expect("the mode created the issue");
    assert_eq!(issue.status.to_string(), "triage");
    assert!(
        issue.entered_at.is_some(),
        "an issue created into the inbox carries the entry stamp the queue ages from"
    );
}