leaf-markdown-viewer 1.26.2

Terminal Markdown previewer with a GUI-like experience
use super::{rendered_non_empty_lines, test_assets, test_md_theme};
use crate::line_plain_text;
use crate::markdown::{parse_markdown, parse_markdown_with_width};

#[test]
fn code_block_after_blockquote_in_list_item_has_no_blank_gap_before() {
    let (ss, theme) = test_assets();
    let src = "- header\n  > quote first\n  ```\n  code after quote\n  ```\n";
    let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered: Vec<String> = lines.iter().map(line_plain_text).collect();

    let quote_idx = rendered
        .iter()
        .position(|l| l == "  ▏ quote first")
        .expect("missing blockquote line");
    let header_idx = rendered
        .iter()
        .position(|l| l.contains("┌─"))
        .expect("missing code block header");
    assert_eq!(
        header_idx,
        quote_idx + 1,
        "expected code block header directly after blockquote line, got {rendered:?}"
    );
}

#[test]
fn blockquote_after_list_item_paragraph_has_no_blank_gap_before() {
    let (ss, theme) = test_assets();
    let src = "1. first item\n   > quote\n\n2. second item\n   > another quote\n";
    let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered: Vec<String> = lines.iter().map(line_plain_text).collect();

    let first_idx = rendered
        .iter()
        .position(|l| l == "1. first item")
        .expect("missing '1. first item'");
    let quote_idx = rendered
        .iter()
        .position(|l| l == "   ▏ quote")
        .expect("missing blockquote line");
    assert_eq!(
        quote_idx,
        first_idx + 1,
        "expected blockquote directly under the item content, got {rendered:?}"
    );
}

#[test]
fn code_block_in_list_item_has_same_layout_loose_and_tight() {
    let (ss, theme) = test_assets();
    let loose_md = "- one\n\n  ```\n  three\n  ```\n";
    let tight_md = "- one\n  ```\n  three\n  ```\n";
    let (loose_lines, _, _, _) =
        parse_markdown(loose_md, &ss, &theme, &test_md_theme(), false, true).into();
    let (tight_lines, _, _, _) =
        parse_markdown(tight_md, &ss, &theme, &test_md_theme(), false, true).into();
    let loose = rendered_non_empty_lines(&loose_lines);
    let tight = rendered_non_empty_lines(&tight_lines);

    let loose_one = loose
        .iter()
        .position(|l| l.contains("one"))
        .expect("missing 'one' in loose rendering");
    let loose_three = loose
        .iter()
        .position(|l| l.contains("three"))
        .expect("missing 'three' in loose rendering");
    let tight_one = tight
        .iter()
        .position(|l| l.contains("one"))
        .expect("missing 'one' in tight rendering");
    let tight_three = tight
        .iter()
        .position(|l| l.contains("three"))
        .expect("missing 'three' in tight rendering");

    assert_eq!(
        loose_three - loose_one,
        tight_three - tight_one,
        "loose and tight paths should place the code block at the same distance from the item line"
    );
}

#[test]
fn blockquote_after_continuation_line_in_tight_list_item_renders_below_it() {
    let (ss, theme) = test_assets();
    let md = "- one\n  two\n  > quote\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);

    let one_idx = rendered
        .iter()
        .position(|line| line.contains("one"))
        .expect("missing first list line");
    let two_idx = rendered
        .iter()
        .position(|line| line.contains("two"))
        .expect("missing continuation line");
    let quote_idx = rendered
        .iter()
        .position(|line| line.contains("quote"))
        .expect("missing blockquote line");

    assert!(
        one_idx < two_idx && two_idx < quote_idx,
        "expected blockquote below continuation line, got one={one_idx}, two={two_idx}, quote={quote_idx}"
    );
}

#[test]
fn nested_list_after_continuation_line_in_tight_list_item_renders_below_it() {
    let (ss, theme) = test_assets();
    let md = "- one\n  two\n  - nested\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);

    let one_idx = rendered
        .iter()
        .position(|line| line.contains("one"))
        .expect("missing first list line");
    let two_idx = rendered
        .iter()
        .position(|line| line.contains("two"))
        .expect("missing continuation line");
    let nested_idx = rendered
        .iter()
        .position(|line| line.contains("nested"))
        .expect("missing nested list line");

    assert!(
        one_idx < two_idx && two_idx < nested_idx,
        "expected nested list below continuation line, got one={one_idx}, two={two_idx}, nested={nested_idx}"
    );
}

#[test]
fn html_block_after_continuation_line_in_tight_list_item_documented_behavior() {
    let (ss, theme) = test_assets();
    let md = "- one\n  two\n  <div>html</div>\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);

    assert!(rendered.iter().any(|line| line.contains("one")));
    assert!(rendered.iter().any(|line| line.contains("two")));
    assert!(
        !rendered.iter().any(|line| line.contains("html")),
        "documented behavior: raw HTML blocks are dropped from the rendered output"
    );
}

#[test]
fn list_items_inside_blockquote_render_bq_marker_before_list_marker() {
    let (ss, theme) = test_assets();
    let md = "> - Step one\n> - Step two\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let step_one = rendered
        .iter()
        .find(|line| line.contains("Step one"))
        .expect("missing 'Step one' line");

    let bar_idx = step_one
        .find('')
        .expect("expected blockquote marker in rendered line");
    let bullet_idx = step_one
        .find("")
        .expect("expected list marker in rendered line");
    assert!(
        bar_idx < bullet_idx,
        "blockquote marker should precede list marker when list is inside blockquote, got {step_one:?}"
    );
}

#[test]
fn list_inside_alert_places_bq_marker_before_list_marker() {
    let (ss, theme) = test_assets();
    let md = "> [!NOTE]\n> Steps to follow:\n> - Step one\n> - Step two\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let step_line = rendered
        .iter()
        .find(|line| line.contains("Step one"))
        .expect("missing 'Step one' line");

    assert!(
        step_line.starts_with("▏ • "),
        "list item inside alert should start with '▏ • ', got {step_line:?}"
    );
}

#[test]
fn alert_header_in_list_item_is_indented_under_item_content() {
    let (ss, theme) = test_assets();
    let md = "- info list item\n  > [!NOTE]\n  > This is a note inside a tight list item.\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let header = rendered
        .iter()
        .find(|line| line.contains("Note"))
        .expect("missing alert header");

    assert!(
        header.starts_with(""),
        "alert header should be indented under item content, got {header:?}"
    );
}

#[test]
fn blockquote_in_tight_list_item_is_indented_under_item_content() {
    let (ss, theme) = test_assets();
    let md = "- one\n  > quote\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let quote_line = rendered
        .iter()
        .find(|line| line.contains("quote"))
        .expect("missing blockquote line");

    assert!(
        quote_line.starts_with(""),
        "blockquote in list item should be indented under item content, got {quote_line:?}"
    );
}

#[test]
fn blockquote_after_continuation_in_tight_list_item_is_indented() {
    let (ss, theme) = test_assets();
    let md = "- one\n  two\n  > quote\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let quote_line = rendered
        .iter()
        .find(|line| line.contains("quote"))
        .expect("missing blockquote line");

    assert!(
        quote_line.starts_with(""),
        "blockquote after continuation should be indented, got {quote_line:?}"
    );
}

#[test]
fn loose_blockquote_in_list_item_is_indented_under_item_content() {
    let (ss, theme) = test_assets();
    let md = "- one\n\n  > quote\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let quote_line = rendered
        .iter()
        .find(|line| line.contains("quote"))
        .expect("missing blockquote line");

    assert!(
        quote_line.starts_with(""),
        "loose blockquote in list item should be indented, got {quote_line:?}"
    );
}

#[test]
fn blockquote_multiline_source_in_list_item_indents_all_lines() {
    let (ss, theme) = test_assets();
    let md = "- one\n  > line1\n  > line2\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let line1 = rendered
        .iter()
        .find(|line| line.contains("line1"))
        .expect("missing line1");
    let line2 = rendered
        .iter()
        .find(|line| line.contains("line2"))
        .expect("missing line2");

    assert!(
        line1.starts_with(""),
        "line1 should be indented: {line1:?}"
    );
    assert!(
        line2.starts_with(""),
        "line2 should be indented: {line2:?}"
    );
}

#[test]
fn blockquote_long_line_in_list_item_wraps_with_correct_indent() {
    let (ss, theme) = test_assets();
    let md = "- one\n  > This is a long blockquote line that should wrap into multiple prefixed lines at a narrow width.\n";
    let (lines, _, _, _) =
        parse_markdown_with_width(md, &ss, &theme, 32, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let quoted: Vec<_> = rendered.iter().filter(|line| line.contains('')).collect();

    assert!(
        quoted.len() >= 2,
        "expected the blockquote to wrap into multiple lines, got {quoted:?}"
    );
    assert!(
        quoted.iter().all(|line| line.starts_with("")),
        "all wrapped blockquote lines should share the list-indent prefix, got {quoted:?}"
    );
}

#[test]
fn nested_blockquote_in_list_item_uses_list_indent() {
    let (ss, theme) = test_assets();
    let md = "- > > deep\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let deep_line = rendered
        .iter()
        .find(|line| line.contains("deep"))
        .expect("missing deep line");

    assert!(
        deep_line.contains(""),
        "list marker should be present on the item opening: {deep_line:?}"
    );
    let markers = deep_line.matches('').count();
    assert_eq!(
        markers, 2,
        "expected 2 blockquote markers for depth 2, got {deep_line:?}"
    );
    let bullet_idx = deep_line.find("").unwrap();
    let bar_idx = deep_line.find('').unwrap();
    assert!(
        bullet_idx < bar_idx,
        "list marker should come before blockquote markers: {deep_line:?}"
    );
}

#[test]
fn nested_blockquote_inside_list_item_uses_depth_markers() {
    let (ss, theme) = test_assets();
    let md = "- > > deep\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let deep_line = rendered
        .iter()
        .find(|line| line.contains("deep"))
        .expect("missing deep line");

    assert!(
        deep_line.contains("▏ ▏ "),
        "expected two blockquote markers in the prefix: {deep_line:?}"
    );
}

#[test]
fn ordered_list_item_with_blockquote_uses_correct_indent() {
    let (ss, theme) = test_assets();
    let md = "1. one\n   > quote\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let quote_line = rendered
        .iter()
        .find(|line| line.contains("quote"))
        .expect("missing blockquote line");

    assert!(
        quote_line.starts_with(""),
        "ordered list item should indent blockquote by 3 columns (width of '1. '), got {quote_line:?}"
    );
}

#[test]
fn nested_list_with_blockquote_uses_deeper_indent() {
    let (ss, theme) = test_assets();
    let md = "- outer\n  - inner\n    > deep quote\n";
    let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into();
    let rendered = rendered_non_empty_lines(&lines);
    let quote_line = rendered
        .iter()
        .find(|line| line.contains("deep quote"))
        .expect("missing deep quote line");

    assert!(
        quote_line.starts_with(""),
        "nested list item should indent blockquote by 4 columns (2 for outer + 2 for inner '◦ '), got {quote_line:?}"
    );
}