qqq-cli 0.6.0

Local-first task queue for coding agent sessions
#[allow(dead_code)]
#[path = "../src/tui/panel.rs"]
mod panel;
#[allow(dead_code)]
#[path = "../src/tui/render.rs"]
mod render;

fn chrome(message: &str) -> render::Chrome<'_> {
    render::Chrome {
        title: "Task Editor",
        title_status_color: None,
        keys: render::KEYS,
        message,
    }
}

#[test]
fn dashboard_rows_keep_task_identity_across_continuations() {
    let rows = panel::rows(
        "ID     STATUS       TASK\n1      New          One\n                    detail\n2      New          Two",
        80,
    );
    assert_eq!(rows.len(), 3);
    assert_eq!(rows[0].task_id, Some(1));
    assert_eq!(rows[1].task_id, Some(1));
    assert_eq!(rows[2].task_id, Some(2));
    assert_eq!(panel::scroll_to(&rows, Some(1), 2, 2), 0);
    assert_eq!(panel::scroll_to(&rows, Some(2), 0, 2), 1);
    assert_eq!(panel::scroll_to(&rows, None, 0, 2), 1);
}

#[test]
fn dashboard_rows_preserve_empty_message_and_header_like_task_text() {
    let rows = panel::rows("No tasks yet.", 80);
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].text, "No tasks yet.");
    assert_eq!(rows[0].task_id, None);

    let rows = panel::rows("ID STATUS TASK\n1 New ID STATUS TASK\nID STATUS TASK", 80);
    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0].text, "1 New ID STATUS TASK");
    assert_eq!(rows[1].text, "ID STATUS TASK");
    assert!(rows.iter().all(|row| row.task_id == Some(1)));
}

#[test]
fn dashboard_task_preview_caps_each_task_at_three_rows() {
    let rows = panel::rows(
        "ID STATUS TASK\n1 New Parent\n  second\n  third\n  hidden\n4 New Child\n  child second\n  child third\n  child hidden\n2 New Other",
        80,
    );
    assert_eq!(rows.len(), 7);
    assert_eq!(rows[2].text, "  third...");
    assert_eq!(rows[5].text, "  child third...");
    assert!(!rows.iter().any(|row| row.text.contains("hidden")));
    assert_eq!(panel::visible_ids(&rows), vec![1, 4, 2]);
    assert_eq!(rows[2].task_id, Some(1));
    assert_eq!(rows[5].task_id, Some(4));
    assert_eq!(panel::scroll_to(&rows, Some(2), 0, 3), 4);
}

#[test]
fn dashboard_task_preview_keeps_exactly_three_rows_without_ellipsis() {
    let tree = "ID STATUS TASK\n1 New First\n  second\n  third\n2 New Last";
    let rows = panel::rows(tree, 80);
    assert_eq!(
        rows.iter().map(|row| row.text.as_str()).collect::<Vec<_>>(),
        tree.lines().skip(1).collect::<Vec<_>>()
    );
}

#[test]
fn dashboard_task_preview_ellipsis_fits_without_splitting_graphemes() {
    for (third, expected) in [
        ("  界界界界界界", "  界界界界..."),
        ("  👩‍💻👩‍💻👩‍💻👩‍💻👩‍💻👩‍💻", "  👩‍💻👩‍💻👩‍💻👩‍💻..."),
        (
            "  e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}",
            "  e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}...",
        ),
    ] {
        let tree = format!("ID STATUS TASK\n1 New First\n  second\n{third}\n  hidden");
        let rows = panel::rows(&tree, 14);
        assert_eq!(rows[2].text, expected);
        assert!(unicode_width::UnicodeWidthStr::width(rows[2].text.as_str()) <= 14);
    }
    for width in 0..=3 {
        let rows = panel::rows("1 New First\n  second\n  third\n  hidden", width);
        assert_eq!(rows[2].text, ".".repeat(width));
    }
}

#[test]
fn dashboard_new_draft_scrolls_to_bottom_of_tree_with_multiline_previews() {
    let rows = panel::rows(
        "ID     STATUS       TASK\n1      New          Parent\n21     New          New child\n  child second\n  child third\n2      New          Older root\n20     New          Last root\n  last second\n  last third",
        80,
    );
    assert_eq!(panel::scroll_to(&rows, None, 0, 2), 6);
    assert_eq!(panel::scroll_to(&rows, Some(21), 6, 2), 1);
}

#[test]
fn mouse_wheel_top_moves_three_rows_and_stops_at_bounds() {
    assert_eq!(panel::wheel_top(0, 20, 5, true), 3);
    assert_eq!(panel::wheel_top(14, 20, 5, true), 15);
    assert_eq!(panel::wheel_top(15, 20, 5, true), 15);
    assert_eq!(panel::wheel_top(1, 20, 5, false), 0);
    assert_eq!(panel::wheel_top(0, 20, 5, false), 0);
    assert_eq!(panel::wheel_top(3, 2, 5, true), 0);
}

#[test]
fn editor_paints_only_body_rows_and_resets_colors_for_header_and_footer() {
    crossterm::style::force_color_output(true);
    let layout = render::Layout::new(&["Body".into()], &[], 40);
    for message in [
        "",
        "Task description cannot be empty",
        "Discard draft? (y/N)",
    ] {
        let mut output = Vec::new();
        render::draw(
            &mut output,
            &layout,
            0,
            &mut 0,
            (40, 8),
            &chrome(message),
            true,
        )
        .unwrap();
        let output = String::from_utf8(output).unwrap();
        assert!(output.starts_with("\x1b[0m"), "{output:?}");
        let background = output.find("\x1b[48;5;236m").unwrap();
        assert!(output.find("Task Editor").unwrap() < background);
        assert!(output.contains("\x1b[38;5;252m"));
        let blank = " ".repeat(40);
        for row in 2..8 {
            assert!(output.contains(&format!("\x1b[{row};1H{blank}")));
        }
        for row in [1, 8] {
            assert!(!output.contains(&format!("\x1b[{row};1H{blank}")));
        }
        let reset = output.rfind("\x1b[0m").unwrap();
        assert!(output.find("Body").unwrap() < reset);
        let footer = if message.is_empty() {
            render::KEYS
        } else {
            message
        };
        let visible_footer: String = footer.chars().take(40).collect();
        assert!(reset < output.find(&visible_footer).unwrap());
        assert!(!output.contains("Whole buffer ="));
    }
}

#[test]
fn resize_hint_uses_default_colors_without_editor_body() {
    crossterm::style::force_color_output(true);
    let mut output = Vec::new();
    render::draw(
        &mut output,
        &render::Layout::new(&["Body".into()], &[], 10),
        0,
        &mut 0,
        (10, 2),
        &chrome(""),
        true,
    )
    .unwrap();
    let output = String::from_utf8(output).unwrap();
    assert!(output.starts_with("\x1b[0m"));
    assert!(output.contains("Resize ter"));
    assert!(!output.contains("\x1b[48;"));
    assert!(!output.contains("\x1b[38;"));
}

#[test]
fn plain_editor_does_not_emit_color_commands() {
    let mut output = Vec::new();
    render::draw(
        &mut output,
        &render::Layout::new(&["Body".into()], &[], 40),
        0,
        &mut 0,
        (40, 8),
        &chrome(""),
        false,
    )
    .unwrap();
    let output = String::from_utf8(output).unwrap();
    assert!(!output.contains("\x1b[48;"));
    assert!(!output.contains("\x1b[38;"));
    assert!(output.contains("Body"));
}

#[test]
fn image_label_gets_distinct_foreground_across_wrapped_rows() {
    crossterm::style::force_color_output(true);
    let fragments = vec![
        "Text ".into(),
        "[Image #1: sample.png]".into(),
        " tail".into(),
    ];
    let layout = render::Layout::new(&fragments, &[false, true, false], 12);
    let mut output = Vec::new();
    render::draw(&mut output, &layout, 0, &mut 0, (12, 8), &chrome(""), true).unwrap();
    let output = String::from_utf8(output).unwrap();
    assert!(output.contains("\x1b[38;5;81m"), "{output:?}");
    assert!(output.matches("\x1b[38;5;81m").count() >= 2, "{output:?}");
    assert!(output.contains("\x1b[38;5;252m tail"), "{output:?}");

    let mut plain = Vec::new();
    render::draw(&mut plain, &layout, 0, &mut 0, (12, 8), &chrome(""), false).unwrap();
    let plain = String::from_utf8(plain).unwrap();
    assert!(!plain.contains("\x1b[38;"));
    assert!(plain.contains("[Image ") && plain.contains("sample.p"));
}

#[test]
fn paste_label_uses_gold_across_wrapped_rows_and_plain_mode_has_no_color() {
    crossterm::style::force_color_output(true);
    let fragments = vec![
        "A".into(),
        "[Pasted Content 1001 chars]".into(),
        " tail".into(),
    ];
    let layout = render::Layout::with_paste(
        &fragments,
        &[false, false, false],
        &[false, true, false],
        12,
    );
    let mut output = Vec::new();
    render::draw(&mut output, &layout, 0, &mut 0, (12, 8), &chrome(""), true).unwrap();
    let output = String::from_utf8(output).unwrap();
    assert!(output.matches("\x1b[38;5;222m").count() >= 2, "{output:?}");
    assert!(output.contains("\x1b[38;5;252m tail"), "{output:?}");

    let mut plain = Vec::new();
    render::draw(&mut plain, &layout, 0, &mut 0, (12, 8), &chrome(""), false).unwrap();
    let plain = String::from_utf8(plain).unwrap();
    assert!(!plain.contains("\x1b[38;"));
}

#[test]
fn editor_reclaims_hint_row_for_body_and_keeps_cursor_above_footer() {
    let layout = render::Layout::new(&["A\nB\nC\nD\nE".into()], &[], 40);
    let mut output = Vec::new();
    let mut top = 0;
    render::draw(
        &mut output,
        &layout,
        1,
        &mut top,
        (40, 5),
        &chrome(""),
        false,
    )
    .unwrap();
    let output = String::from_utf8(output).unwrap();
    assert_eq!(top, 2);
    for (row, text) in [(2, "C"), (3, "D"), (4, "E")] {
        assert!(
            output.contains(&format!("\x1b[{row};1H{text}")),
            "{output:?}"
        );
    }
    assert!(output.contains("\x1b[5;1HCtrl-S"));
    assert!(output.ends_with("\x1b[4;2H"));
}

#[test]
fn preserved_crlf_displays_as_line_break() {
    let layout = render::Layout::new(&["First".into(), "\r\n".into(), "Second".into()], &[], 20);
    assert_eq!(layout.rows, ["First", "Second"]);
    assert_eq!(layout.positions[2], (1, 0));
}

#[test]
fn newline_after_exact_width_does_not_add_blank_visual_row() {
    let layout = render::Layout::new(&["abcd".into(), "\n".into(), "x".into()], &[], 4);
    assert_eq!(layout.rows, vec!["abcd", "x"]);
    assert_eq!(layout.positions, vec![(0, 0), (1, 0), (1, 0), (1, 1)]);
    assert_eq!(layout.nearest(1, 0), 2);
}

#[test]
fn layout_wraps_unicode_and_escapes_terminal_controls() {
    let layout = render::Layout::new(
        &[
            "界".into(),
            "👩‍💻".into(),
            "\x1b[31m".into(),
            "\n".into(),
            "tail".into(),
        ],
        &[],
        4,
    );
    assert_eq!(layout.positions[0], (0, 0));
    assert_eq!(layout.positions[1], (0, 2));
    assert_eq!(layout.positions[2], (1, 0));
    assert!(layout.rows.concat().contains("\\u{1b}[31m"));
    assert!(layout.rows.iter().all(|line| !line.contains('\x1b')));
    assert!(
        layout
            .rows
            .iter()
            .all(|line| unicode_width::UnicodeWidthStr::width(line.as_str()) <= 4)
    );
}
#[test]
fn narrow_layout_preserves_atomic_placeholder_cursor_boundaries() {
    let fragments = vec![
        "Title".into(),
        "\n".into(),
        "[Pasted text #1: 1001 chars]".into(),
    ];
    let layout = render::Layout::new(&fragments, &[], 5);
    assert_eq!(layout.positions.len(), 4);
    let start = layout.positions[2];
    assert_eq!(layout.nearest(start.0, start.1), 2);
    assert!(layout.positions[3].0 > start.0);
}