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::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
use tempfile::TempDir;

#[test]
/// #1307: a double-width character must not defeat the cut marker.
///
/// The previous truncation took `chars().take(budget)` — scalar values, not
/// display cells — so a CJK filename produced a string up to twice as wide as
/// the status area. ratatui's `Paragraph` then hard-cut it at the right edge
/// and discarded the ellipsis appended at the end of the overflowed string,
/// which is the silent cut the truncation was added to prevent.
///
/// Driven through the real renderer rather than the helper, because the bug
/// lived in the interaction between the two: the helper "worked" and the
/// widget threw the marker away.
fn the_cut_marker_survives_a_double_width_filename() {
    let (_tmp, mut app) = make_app();
    app.note_disclosure(
            "remove `links:` by hand to silence \u{2014} \
             alice/2026-09-14-\u{65e5}\u{672c}\u{8a9e}\u{306e}\u{3068}\u{3066}\u{3082}\u{3044}\u{3044}\u{30d5}\u{30a1}\u{30a4}\u{30eb}\u{540d}_tests.md \
             (retired by C-001; never read as typed data, no migration)"
                .to_string(),
        );
    // 60 is included: the review noted the existing test omitted it, and
    // omitted widths are where this class of bug hides.
    for width in [60u16, 80, 100, 120, 200] {
        let cells = render_to_cells(&app, width, 40).unwrap();
        let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
        let status = rendered
            .iter()
            .find(|l| l.contains("remove `links:`"))
            .unwrap_or_else(|| panic!("width {width}: no status row: {rendered:?}"));
        assert!(
            status.contains("remove `links:` by hand"),
            "width {width}: the action must stay visible, got {status:?}"
        );
        if width < 200 {
            assert!(
                status.contains('\u{2026}'),
                "width {width}: a cut must be marked, not silent, got {status:?}"
            );
        }
    }
}

/// #1307: the rendered row must never exceed the area, in cells.
///
/// This is the invariant the widget silently broke: a string handed to a
/// one-row area that is wider than the area gets hard-cut, and whatever sat
/// at the end of it — the marker — is lost.
#[test]
#[allow(clippy::unicode_not_nfc)] // the CJK literals are deliberately un-normalised
fn a_truncated_status_row_never_exceeds_its_width_in_cells() {
    use unicode_width::UnicodeWidthStr;
    for msg in [
        "remove `links:` by hand to silence \u{2014} plain-ascii-file.md",
        "remove `links:` by hand to silence \u{2014} \u{65e5}\u{672c}\u{8a9e}.md",
        "short",
        "",
    ] {
        for budget in [1usize, 2, 3, 5, 10, 20] {
            let text = crate::ui::board::truncate_to_cells(msg, budget);
            assert!(
                text.width() <= budget,
                "budget {budget}, msg {msg:?}: produced {text:?} at width {}",
                text.width()
            );
            if msg.width() > budget {
                assert!(
                    text.ends_with('\u{2026}'),
                    "budget {budget}, msg {msg:?}: a cut must be marked, got {text:?}"
                );
            }
        }
    }
}

#[test]
fn frame_hit_regions_are_empty_until_recorded_and_resolve_to_actions() {
    let (_tmp, app) = make_app();
    assert!(
        app.frame_hit_regions.borrow().is_empty(),
        "no regions before a draw"
    );

    let rect_a = Rect::new(0, 0, 10, 5);
    let rect_b = Rect::new(2, 1, 2, 2);
    app.record_region(rect_a, Action::OpenEditor("a.md".into()));
    app.record_region(rect_b, Action::Quit);
    assert_eq!(app.frame_hit_regions.borrow().len(), 2);

    // Recorded rects resolve to their action...
    assert_eq!(
        app.resolve_region(0, 0),
        Some(Action::OpenEditor("a.md".into()))
    );
    // ...innermost (last-recorded) regions win on overlap...
    assert_eq!(app.resolve_region(3, 2), Some(Action::Quit));
    // ...and positions outside every region resolve to nothing.
    assert_eq!(app.resolve_region(11, 2), None);
    assert_eq!(app.resolve_region(3, 6), None);
}

#[test]
fn draw_clears_regions_so_stale_targets_never_fire() {
    use ratatui::backend::TestBackend;
    let (_tmp, app) = make_app();
    app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
    app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
    let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
    terminal.draw(|f| render(&app, f, f.area())).unwrap();
    assert!(
        !app.frame_hit_regions
            .borrow()
            .iter()
            .any(|region| region.action == Action::Quit),
        "a draw clears regions recorded before it"
    );
}

#[test]
fn f4_the_loop_drain_moves_a_raised_disclosure_into_the_status_line() {
    // Cycle-5 Major 4. The previous test called `App::note_disclosure`
    // directly, so deleting the drain from the event loop left every test
    // green — the unit test could not fail for the regression it was named
    // for. This goes through `drain_disclosures`, the function the loop
    // itself calls, so emptying that function fails here.
    let (_tmp, mut app) = make_app();
    app.status_msg = Some("Moved #1 to in progress".to_string());

    crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
    assert_eq!(
        crate::storage::take_disclosures().len(),
        1,
        "precondition: a disclosure is queued and nothing has drained it"
    );
    // Re-raise it: the line above consumed the queue.
    crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");

    super::super::drain_disclosures(&mut app);
    let status = app.status_msg.as_deref().unwrap_or_default();
    assert!(
        status.contains("Moved #1 to in progress"),
        "the confirmation survives: {status:?}"
    );
    assert!(
        status.contains("retired"),
        "and the loop's drain moved the disclosure in: {status:?}"
    );
    assert!(
        crate::storage::take_disclosures().is_empty(),
        "the queue is empty, so the next frame does not repeat it"
    );
}

#[test]
fn render_once_uses_default_board_configuration() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path().join("issues");
    fs::create_dir_all(&dir).unwrap();

    let config_path = tmp.path().join("tix.toml");
    let config = Config {
        issues_dir: dir.clone(),
        ..test_config()
    };
    config.save(&config_path).unwrap();
    let mut config = Config::load_with_warning(&config_path, |_| {}).unwrap();
    config.kanban.configurations.clear();
    config.kanban.configurations.insert(
        "release".to_string(),
        crate::config::BoardConfiguration {
            columns: vec!["todo".to_string(), "done".to_string()],
            done_column: Some("done".to_string()),
            category_map: indexmap::IndexMap::new(),
            hidden_columns: vec![],
            show_mismatch: false,
            wrap_titles: true,
            title_lines: 2,
            card_order: vec![],
            order_by: crate::config::OrderBy::CreatedAtDesc,
            wip: crate::wip::WipConfig::default(),
        },
    );
    config.kanban.configurations.insert(
        "default".to_string(),
        crate::config::BoardConfiguration {
            columns: vec!["backlog".to_string(), "done".to_string()],
            done_column: Some("done".to_string()),
            category_map: indexmap::IndexMap::new(),
            hidden_columns: vec![],
            show_mismatch: false,
            wrap_titles: true,
            title_lines: 2,
            card_order: vec![],
            order_by: crate::config::OrderBy::CreatedAtDesc,
            wip: crate::wip::WipConfig::default(),
        },
    );
    config.save(&config_path).unwrap();
    let config = Config::load_with_warning(&config_path, |_| {}).unwrap();

    write_issue(&dir, "2026-01-01-a.md", 1, "A", "backlog", None);
    let rendered = render_once_at(&config, 80, 24).unwrap();
    assert!(
        rendered.contains("backlog"),
        "rendered board should include default board columns, got:\n{rendered}"
    );
}

#[test]
fn render_once_tiny_terminal_is_stable() {
    let (tmp, _app) = make_app();
    let config_path = tmp.path().join("tix.toml");
    let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
    write_issue(
        &tmp.path().join("issues"),
        "2026-01-01-a.md",
        1,
        "Alpha",
        "todo",
        None,
    );
    let first = render_once_at(&config, 20, 4).unwrap();
    let second = render_once_at(&config, 20, 4).unwrap();
    assert_eq!(first, second, "tiny render should be deterministic");
}

#[test]
fn render_once_omits_keyboard_hints() {
    let (tmp, _app) = make_app();
    let config_path = tmp.path().join("tix.toml");
    let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
    write_issue(
        &tmp.path().join("issues"),
        "2026-01-01-a.md",
        1,
        "Alpha",
        "todo",
        None,
    );
    let rendered = render_once_at(&config, 80, 24).unwrap();
    for hint in [
        "j/k nav",
        "n new",
        "? help",
        "h/l column",
        "enter open",
        "d delete",
    ] {
        assert!(
            !rendered.contains(hint),
            "one-shot output must not contain the keyboard hint {hint:?}:\n{rendered}"
        );
    }
}

#[test]
fn render_once_reserves_bottom_margin_lines() {
    let (tmp, _app) = make_app();
    let config_path = tmp.path().join("tix.toml");
    let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
    write_issue(
        &tmp.path().join("issues"),
        "2026-01-01-a.md",
        1,
        "Alpha",
        "todo",
        None,
    );
    let height = 24u16;
    let rendered = render_once_at(&config, 80, height).unwrap();
    let line_count = rendered.lines().count();
    assert!(
        line_count <= (height - RENDER_BOTTOM_MARGIN) as usize,
        "one-shot render must leave {RENDER_BOTTOM_MARGIN} spare lines, \
             got {line_count} lines for a {height}-row terminal:\n{rendered}"
    );
    assert!(
        rendered.lines().any(|l| l.chars().count() == 80),
        "one-shot render should still span the full width:\n{rendered}"
    );
}

/// Choosing a Priority writes that row, and inline `+` still reaches a
/// genuinely new custom priority — a preserved model value (tix #1508).
#[test]
fn choosing_a_priority_writes_the_row_and_inline_create_still_works() {
    let (_tmp, mut app) = make_app();
    app.handle_key(press(KeyCode::F(1))).unwrap();
    app.handle_key(press(KeyCode::Down)).unwrap(); // Title -> Status
    app.handle_key(press(KeyCode::Down)).unwrap(); // Status -> Priority

    // Pick the built-in `high` (the third option).
    app.handle_key(press(KeyCode::Enter)).unwrap();
    for _ in 0..2 {
        app.handle_key(press(KeyCode::Down)).unwrap();
    }
    app.handle_key(press(KeyCode::Enter)).unwrap();
    assert_eq!(app.form.as_ref().unwrap().priority, "high");

    // `+` invents a custom priority the options do not list.
    app.handle_key(press(KeyCode::Char('+'))).unwrap();
    for c in "blocker".chars() {
        app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    app.handle_key(press(KeyCode::Enter)).unwrap();
    assert_eq!(app.form.as_ref().unwrap().priority, "blocker");
}

/// Status refuses `+` inline create (tix #1508): a column the board does not
/// configure is not a column the form can add.
#[test]
fn the_status_dropdown_refuses_inline_create() {
    let (_tmp, mut app) = make_app();
    app.handle_key(press(KeyCode::F(1))).unwrap();
    app.handle_key(press(KeyCode::Down)).unwrap(); // Title -> Status
    app.handle_key(press(KeyCode::Enter)).unwrap();
    app.handle_key(press(KeyCode::Char('+'))).unwrap();
    assert_eq!(
        app.form_picker.as_ref().unwrap().state.mode,
        PickerMode::Idle,
        "Status refuses the inline-create prompt"
    );
    for c in "legacy".chars() {
        app.handle_key(press(KeyCode::Char(c))).unwrap();
    }
    assert!(
        app.form_picker
            .as_ref()
            .unwrap()
            .state
            .create_buffer
            .is_empty(),
        "the swallowed create keys never reach a buffer"
    );
}

/// The dropdown is a real surface (tix #1508): its title and footer render,
/// and the Status footer does not advertise the inline-create key it refuses
/// while the Priority footer does.
#[test]
fn the_status_and_priority_dropdowns_render_their_footers() {
    let (_tmp, mut app) = make_app();
    app.handle_key(press(KeyCode::F(1))).unwrap();
    app.handle_key(press(KeyCode::Down)).unwrap(); // Title -> Status
    app.handle_key(press(KeyCode::Enter)).unwrap();
    // Wide enough that the picker footer is not clipped mid-word.
    let text = cells_to_text(&draw_board(&app, 120, 30), 120, 30);
    assert!(
        text.contains("Status picker"),
        "the Status dropdown renders:\n{text}"
    );
    let footer = text
        .lines()
        .find(|line| line.contains("close |"))
        .expect("the picker footer renders");
    assert!(
        footer.contains("select"),
        "the footer names select: {footer:?}"
    );
    assert!(
        !footer.contains("create"),
        "Status must not offer the create key it refuses: {footer:?}"
    );
    app.handle_key(press(KeyCode::Esc)).unwrap();

    app.handle_key(press(KeyCode::Down)).unwrap(); // Status -> Priority
    app.handle_key(press(KeyCode::Enter)).unwrap();
    let text = cells_to_text(&draw_board(&app, 120, 30), 120, 30);
    assert!(
        text.contains("Priority picker"),
        "the Priority dropdown renders:\n{text}"
    );
    let footer = text
        .lines()
        .find(|line| line.contains("close |"))
        .expect("the picker footer renders");
    assert!(
        footer.contains("create"),
        "Priority keeps inline create: {footer:?}"
    );
}