tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Every advertised top-level action must produce a visible outcome.

use std::fs;

use crate::app::{Action, App};
use crate::config::Config;
use crate::ui::commandbar;
use crate::ui::nav::TopMode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tempfile::TempDir;

fn empty_app() -> (TempDir, App) {
    let tmp = TempDir::new().unwrap();
    let issues = tmp.path().join("issues");
    fs::create_dir_all(&issues).unwrap();
    let config_path = tmp.path().join("tix.toml");
    let mut config = Config {
        issues_dir: issues,
        ..Config::starter()
    };
    config.layout.move_on_change = false;
    config.save(&config_path).unwrap();
    let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
    let app = App::new(&config).unwrap();
    (tmp, app)
}

fn rendered(app: &App) -> String {
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    let mut terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
    terminal
        .draw(|frame| crate::app::render(app, frame, frame.area()))
        .unwrap();
    format!("{:?}", terminal.backend().buffer())
}

fn rendered_text(app: &App) -> String {
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    let mut terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
    terminal
        .draw(|frame| crate::app::render(app, frame, frame.area()))
        .unwrap();
    let buffer = terminal.backend().buffer();
    (0..buffer.area.height)
        .map(|y| {
            (0..buffer.area.width)
                .map(|x| buffer[(x, y)].symbol())
                .collect::<String>()
        })
        .collect::<Vec<_>>()
        .join("\n")
}

#[test]
fn every_advertised_action_has_a_visible_outcome_when_nothing_is_eligible() {
    let mut silent = Vec::new();
    for mode in TopMode::ALL {
        let context = commandbar::hint_context_for_mode(mode);
        let actions = {
            let (tmp, app) = empty_app();
            let actions = app.bindings.bound_actions(context);
            drop(tmp);
            actions
        };
        for action in actions {
            let (_tmp, mut app) = empty_app();
            app.switch_top_mode(mode).unwrap();
            let expected_refusal = app.action_refusal(context, action).is_some();
            let before = rendered(&app);
            let key = app.bindings.canonical_event(context, action);
            let result = app.handle_key(key).unwrap();
            let after = rendered(&app);
            if expected_refusal {
                if let Some(feedback) = app.action_feedback.as_ref() {
                    assert_ne!(feedback.reason, "");
                    assert_ne!(feedback.remedy, "");
                    let text = rendered_text(&app);
                    assert!(
                        text.contains("unavailable:") && text.contains("remedy:"),
                        "{mode:?} / {} did not render both halves:\n{text}",
                        action.as_str()
                    );
                } else {
                    assert_ne!(
                        before,
                        after,
                        "{mode:?} / {} bypassed the refusal without a visible outcome",
                        action.as_str()
                    );
                }
            }
            if before == after && matches!(result, Action::None) {
                silent.push(format!(
                    "{:?} / {} ({})",
                    mode,
                    action.as_str(),
                    app.bindings.label(context, action)
                ));
            }
        }
    }
    assert!(
        silent.is_empty(),
        "these advertised actions produced no visible outcome in an empty fixture:\n  {}",
        silent.join("\n  ")
    );
}

#[test]
fn a_refusal_is_consumed_by_the_next_key_and_does_not_leak_across_modes() {
    let (_tmp, mut app) = empty_app();
    let action = app.handle_key(press_enter()).unwrap();
    assert!(matches!(action, Action::None));
    assert!(
        app.action_feedback
            .as_ref()
            .is_some_and(|feedback| feedback.remedy.contains("move the cursor"))
    );

    app.handle_key(press_char('?')).unwrap();
    assert!(
        app.action_feedback.is_none(),
        "a new accepted key must consume the old refusal"
    );
    assert!(
        !rendered_text(&app).contains("unavailable:"),
        "the old refusal must not leak into Help"
    );

    app.switch_top_mode(TopMode::Library).unwrap();
    assert!(app.action_feedback.is_none());
}

#[test]
fn advertised_raw_keys_also_have_a_visible_empty_state_outcome() {
    fn cycle_tab(app: &mut App) {
        app.schedule.as_mut().unwrap().tab = crate::ui::schedule::ScheduleTab::Cycles;
    }

    fn roadmap_tab(app: &mut App) {
        app.portfolio.as_mut().unwrap().tab = crate::ui::portfolio::PortfolioTab::Roadmap;
        app.portfolio_roadmap = None;
    }

    type RawKeyCase = (TopMode, fn(&mut App), &'static [(KeyCode, KeyModifiers)]);
    let cases: &[RawKeyCase] = &[
        (
            TopMode::Triage,
            |_| {},
            &[
                (KeyCode::Char(' '), KeyModifiers::NONE),
                (KeyCode::Char('A'), KeyModifiers::NONE),
                (KeyCode::Char('a'), KeyModifiers::NONE),
                (KeyCode::Char('d'), KeyModifiers::NONE),
                (KeyCode::Char('u'), KeyModifiers::NONE),
                (KeyCode::Char('f'), KeyModifiers::NONE),
                (KeyCode::Char('p'), KeyModifiers::NONE),
                (KeyCode::Char('s'), KeyModifiers::NONE),
                (KeyCode::Char('@'), KeyModifiers::NONE),
            ],
        ),
        (
            TopMode::Schedule,
            cycle_tab,
            &[
                (KeyCode::Tab, KeyModifiers::NONE),
                (KeyCode::Char('n'), KeyModifiers::NONE),
                (KeyCode::Char('E'), KeyModifiers::NONE),
                (KeyCode::Char('c'), KeyModifiers::NONE),
                (KeyCode::Char('R'), KeyModifiers::NONE),
                (KeyCode::Char('e'), KeyModifiers::NONE),
                (KeyCode::Char('s'), KeyModifiers::NONE),
                (KeyCode::Char('a'), KeyModifiers::NONE),
            ],
        ),
        (
            TopMode::Portfolio,
            roadmap_tab,
            &[
                (KeyCode::Char('c'), KeyModifiers::NONE),
                (KeyCode::Char('e'), KeyModifiers::NONE),
                (KeyCode::Char('a'), KeyModifiers::NONE),
                (KeyCode::Char('x'), KeyModifiers::NONE),
                (KeyCode::Char('f'), KeyModifiers::NONE),
                (KeyCode::Char('r'), KeyModifiers::NONE),
            ],
        ),
    ];

    let mut silent = Vec::new();
    for (mode, prepare, keys) in cases {
        for (code, modifiers) in *keys {
            let (_tmp, mut app) = empty_app();
            app.switch_top_mode(*mode).unwrap();
            prepare(&mut app);
            let before = rendered(&app);
            app.handle_key(KeyEvent::new(*code, *modifiers)).unwrap();
            if app.action_feedback.is_some() {
                let text = rendered_text(&app);
                assert!(
                    text.contains("unavailable:") && text.contains("remedy:"),
                    "{mode:?} / {code:?} did not render both refusal halves:\n{text}"
                );
            }
            if before == rendered(&app) {
                silent.push(format!("{mode:?} / {code:?}"));
            }
        }
    }
    assert!(
        silent.is_empty(),
        "these advertised raw keys had no visible empty-state outcome:\n  {}",
        silent.join("\n  ")
    );
}

fn press_enter() -> KeyEvent {
    KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}

fn press_char(c: char) -> KeyEvent {
    KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}