use ratatui::style::{Color, Modifier}; use yame::config::Theme; use yame::decoration::build_decoration_map; use yame::highlighting::HighlightCache; /// Full decoration pass against the fixture file. /// Confirms headings, bold, blockquotes, and word count all survive the pipeline. #[test] fn fixture_decoration_roundtrip() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, word_count) = build_decoration_map(text, &theme, true, None); // Line 0 ("# Heading One") must have full_line_bg == theme.heading_bg. // (Checking the exact value, not just is_some(), so delete-field mutations are caught.) assert!( map[&0] .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)), "H1 on line 0 must have full_line_bg == theme.heading_bg" ); // Line 10 is the normal-paragraph line containing **bold content**. // Check that BOLD appears on that specific line, not just somewhere in the file. assert!( map[&10] .iter() .any(|s| s.style.add_modifier.contains(Modifier::BOLD)), "line 10 (**bold content**) must contain at least one BOLD span" ); // Line 46 ("> A single-line blockquote.") must have a blockquote indicator. assert!( map[&46].iter().any(|s| s.is_blockquote), "line 46 ('> A single-line blockquote.') must have an is_blockquote span" ); // The fixture has more than 100 words. assert!( word_count > 100, "word count was {word_count} — expected >100" ); } /// Strikethrough syntax (~~text~~) must produce a CROSSED_OUT span. /// /// Line 38 (0-indexed) of the fixture is "~~Strikethrough.~~". /// The content span "Strikethrough." at chars 2..16 must carry CROSSED_OUT. #[test] fn fixture_has_strikethrough() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Check the specific line rather than any-line — kills whole-function and // line-replacement mutations that emit CROSSED_OUT on the wrong line. let strike_line = map .get(&38) .expect("line 38 ('~~Strikethrough.~~') must have spans"); assert!( strike_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT)), "line 38 ('~~Strikethrough.~~') must have a CROSSED_OUT span" ); // The opening ~~ delimiters are at chars 0..2; content starts at char 2. assert!( strike_line .iter() .any(|s| s.char_start == 2 && s.style.add_modifier.contains(Modifier::CROSSED_OUT)), "strikethrough content on line 38 must start at char 2 (after ~~)" ); } /// Link text must be underlined per the decoration spec. /// /// Line 30 (0-indexed) is "A [link to example](https://example.com) ...". /// The link text "link to example" at chars 3..18 must be UNDERLINED with /// fg == theme.link_text. #[test] fn fixture_has_link_underline() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let link_line = map .get(&30) .expect("line 30 ('A [link to example]...') must have spans"); // Link text must be UNDERLINED on this specific line. assert!( link_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::UNDERLINED)), "line 30 must have an UNDERLINED span for the link text" ); // Link text must also carry theme.link_text as its foreground color. assert!( link_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::UNDERLINED) && s.style.fg == Some(theme.link_text)), "link text on line 30 must be UNDERLINED with fg == theme.link_text" ); } /// Horizontal rules (`---`) must set is_rule on their span. #[test] fn fixture_has_horizontal_rule() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); assert!( map.values().flatten().any(|s| s.is_rule), "expected at least one is_rule span (---) in the fixture" ); } /// Fenced code block content lines must receive the fenced_bg full-line background. #[test] fn fixture_has_fenced_code_bg() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); assert!( map.values() .flatten() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "expected at least one span with fenced_bg full-line background" ); } /// The fixture has H1–H4; all should carry heading_bg on at least one span. /// Checks that multiple distinct heading lines are decorated (not just H1). #[test] fn fixture_multiple_heading_levels_decorated() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let heading_lines = map .values() .filter(|spans| { spans .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)) }) .count(); assert!( heading_lines >= 4, "expected ≥4 heading lines with heading_bg, got {heading_lines}" ); } /// Heading background must not bleed into the paragraph that follows. /// /// If `headings::on_end` is not called (e.g., the `Event::End(TagEnd::Heading(_))` /// match arm in `build_decoration_map` is deleted), `s.in_heading_bg` is never /// cleared and every subsequent line inherits the heading background. #[test] fn heading_bg_does_not_bleed_past_heading_line() { let text = "# Heading\n\nNormal text\n"; let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Line 0 must have heading_bg (basic sanity). assert!( map[&0] .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)), "line 0 must have heading_bg" ); // Line 2 ("Normal text") must NOT carry heading_bg. let bleeds = map .get(&2) .map(|spans| { spans .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)) }) .unwrap_or(false); assert!( !bleeds, "heading_bg must not bleed into line 2 (normal paragraph after heading)" ); } // --------------------------------------------------------------------------- // Continuation indent (regression guard for the list-item clipping bug) // --------------------------------------------------------------------------- /// Unordered bullet spans must carry continuation_indent >= 2. /// /// This is the integration-level guard for the list-item soft-wrap clipping /// bug: if continuation_indent is zero, wrap_line_indented degenerates to /// wrap_line and continuation rows overflow the right edge. #[test] fn fixture_unordered_list_has_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Bullet spans anchor at char_start == 0, char_end == 1 ("- "). assert!( map.values() .flatten() .any(|s| s.char_start == 0 && s.char_end == 1 && s.continuation_indent >= 2), "expected at least one unordered bullet span with continuation_indent >= 2" ); } /// Blockquote spans must carry continuation_indent == 2. /// /// Guards that the `> ` prefix (2 columns) is accounted for when wrapping /// long blockquote lines, preventing right-edge overflow on continuation rows. #[test] fn fixture_blockquote_has_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); assert!( map.values() .flatten() .any(|s| s.is_blockquote && s.continuation_indent == 2), "expected at least one blockquote span with continuation_indent == 2" ); } /// Task-list items must produce a maximum continuation_indent >= 6. /// /// The task checkbox prefix `- [ ] ` / `- [x] ` is 6 columns wide; the /// continuation indent must reach at least that far so wrapped task items /// align correctly under the text, not under the dash. #[test] fn fixture_task_list_has_max_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let max_ci = map .values() .flatten() .map(|s| s.continuation_indent) .max() .unwrap_or(0); assert!( max_ci >= 6, "expected max continuation_indent >= 6 for task-list items, got {max_ci}" ); } // --------------------------------------------------------------------------- // Bold + italic combined // --------------------------------------------------------------------------- /// `***text***` must produce a span with both BOLD and ITALIC modifiers. /// /// Line 40 (0-indexed) is "***Bold and italic combined with triple asterisks.***". /// The content at chars 3..49 must carry both BOLD and ITALIC modifiers. #[test] fn fixture_has_bold_italic_combined() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let bold_italic_line = map .get(&40) .expect("line 40 ('***Bold and italic...***') must have spans"); assert!( bold_italic_line.iter().any(|s| { s.style.add_modifier.contains(Modifier::BOLD) && s.style.add_modifier.contains(Modifier::ITALIC) }), "line 40 must have a span with both BOLD and ITALIC modifiers" ); // Content starts at char 3 (after opening ***). assert!( bold_italic_line.iter().any(|s| { s.char_start == 3 && s.style.add_modifier.contains(Modifier::BOLD) && s.style.add_modifier.contains(Modifier::ITALIC) }), "BOLD+ITALIC content on line 40 must start at char 3 (after ***)" ); } // --------------------------------------------------------------------------- // Custom TOML theme propagation (end-to-end config → theme → decoration) // --------------------------------------------------------------------------- /// A custom palette TOML loads correctly and its accent propagates to every /// accent-derived theme token, which the full decoration pipeline then uses. /// /// The fixture `tests/fixtures/custom_theme.toml` overrides only `accent`. /// We verify: /// 1. The file parses without warnings. /// 2. `heading_bg` (derived as blend(accent, bg, 0.15)) differs from the /// default (proving the new accent reached theme derivation). /// 3. The decoration map contains at least one span whose `full_line_bg` /// equals the custom `heading_bg` (proving the theme reached rendering). #[test] fn fixture_custom_toml_theme_propagates() { let text = include_str!("fixtures/sample.md"); let toml_str = include_str!("fixtures/custom_theme.toml"); let cfg: yame::config::Config = toml::from_str(toml_str).expect("custom_theme.toml must parse as a valid Config"); let mut warnings = Vec::new(); let custom_theme = Theme::from_config(&cfg.palette, &cfg.theme, &cfg.headings, &mut warnings); assert!( warnings.is_empty(), "unexpected parse warnings from custom_theme.toml: {warnings:?}" ); // heading_bg is accent-derived; a red accent must move it away from the // default purple-tinted value. let default_theme = Theme::default_theme(); assert_ne!( custom_theme.heading_bg, default_theme.heading_bg, "custom accent (#ff0000) must change heading_bg away from the Catppuccin default" ); // Run the full decoration pipeline with the custom theme and confirm that // at least one heading span carries the custom heading_bg. let (map, _) = build_decoration_map(text, &custom_theme, true, None); assert!( map.values() .flatten() .any(|s| s.full_line_bg == Some(custom_theme.heading_bg)), "expected at least one heading span with the custom heading_bg in the decoration map" ); } // --------------------------------------------------------------------------- // Syntax highlighting integration (HighlightCache → build_decoration_map) // --------------------------------------------------------------------------- /// With a live HighlightCache, a fenced Rust block must produce spans whose /// foreground is Color::Rgb (syntect-sourced) rather than the plain theme.text /// fallback colour. #[test] fn highlighted_rust_block_has_rgb_fg_spans() { let text = "# Doc\n\n```rust\nfn main() {}\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache)); // Content line of the fenced block is line index 3 ("fn main() {}"). // It should have at least one span whose fg is Color::Rgb(_,_,_). let content_line = map.get(&3).expect("line 3 (code content) must have spans"); assert!( content_line .iter() .any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _)))), "highlighted fenced block line must have at least one Color::Rgb fg span" ); } /// With highlighting disabled, a fenced block must still get full_line_bg /// (fenced_bg) but must NOT have any Color::Rgb fg spans beyond theme colours /// (i.e. syntect didn't run). #[test] fn disabled_highlighting_fenced_block_has_bg_no_syntect_fg() { let text = "```rust\nlet x = 1;\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(false, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache)); // Content line is line 1. let content_line = map.get(&1).expect("line 1 (code content) must have spans"); // Must have the fenced_bg full-line background. assert!( content_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "disabled-highlighting fenced line must still have fenced_bg full_line_bg" ); // Must NOT have a syntect fg span (no Color::Rgb that differs from the // plain theme colours — specifically the single span should be theme.text fg). // We verify this by checking the fg is theme.text, not an arbitrary Rgb value. assert!( content_line.iter().all(|s| { // Acceptable fgs: theme.text (the plain fence_bg_style fg) or None. s.style.fg.is_none() || s.style.fg == Some(theme.text) }), "disabled-highlighting fenced line must not have syntect-sourced fg colours" ); } /// Passing `None` for the cache produces the same fenced_bg fallback as a /// disabled cache — the two paths must be indistinguishable. #[test] fn no_cache_fenced_block_falls_back_to_fenced_bg() { let text = "```python\nprint('hello')\n```\n"; let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let content_line = map.get(&1).expect("line 1 must have spans"); assert!( content_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "None-cache fenced block must have fenced_bg full_line_bg on content lines" ); } /// The fence delimiter lines (``` opening and closing) must always use the /// blended fence_delim_style regardless of whether highlighting is active. #[test] fn highlighted_block_fence_delimiters_still_dimmed() { let text = "```rust\nlet x = 1;\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache)); // Opening fence is line 0, closing is line 2. for fence_line in [0usize, 2] { let spans = map .get(&fence_line) .unwrap_or_else(|| panic!("fence line {fence_line} must have spans")); assert!( spans .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "fence delimiter line {fence_line} must have fenced_bg full_line_bg" ); } } /// An unknown language tag must silently fall back to fenced_bg-only /// (no panic, no empty map) even when a cache is present. #[test] fn unknown_lang_tag_falls_back_silently() { let text = "```notareallanguage\nsome code here\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache)); let content_line = map.get(&1).expect("line 1 must have spans"); assert!( content_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "unknown lang fenced block must fall back to fenced_bg on content lines" ); } // --------------------------------------------------------------------------- // Inline code // --------------------------------------------------------------------------- /// Inline code spans (`` `code` ``) must receive fg == theme.code_color. /// /// Line 34 (0-indexed) is "`inline code` at the start, ...". /// The content "inline code" at chars 1..12 must have fg == theme.code_color. #[test] fn fixture_inline_code_has_code_color() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Line 34 starts with a backtick-delimited span — check this specific line. let code_line = map .get(&34) .expect("line 34 ('`inline code` at the start...') must have spans"); assert!( code_line .iter() .any(|s| s.style.fg == Some(theme.code_color)), "line 34 must have a span with fg == theme.code_color for inline code" ); // The inline-code content starts at char 1 (after the opening backtick). assert!( code_line .iter() .any(|s| s.char_start == 1 && s.style.fg == Some(theme.code_color)), "inline code content on line 34 must start at char 1 (after the opening `)" ); } // --------------------------------------------------------------------------- // Italic // --------------------------------------------------------------------------- /// Line 10 must carry the ITALIC modifier on the `*italic content*` span. /// /// Line 10 (0-indexed) is the normal-paragraph line containing both /// `**bold content**` and `*italic content*`; italic must be emitted as a /// distinct modifier, not collapsed into the bold path. #[test] fn fixture_italic_has_italic_modifier() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let para_line = map .get(&10) .expect("line 10 (normal paragraph with *italic*) must have spans"); assert!( para_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::ITALIC)), "line 10 must have an ITALIC span for *italic content*" ); // Italic must use theme.italic_color, not default text color. assert!( para_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::ITALIC) && s.style.fg == Some(theme.italic_color)), "italic span on line 10 must have fg == theme.italic_color" ); } // --------------------------------------------------------------------------- // Ordered list continuation indent // --------------------------------------------------------------------------- /// Ordered-list bullet spans must carry continuation_indent >= 3. /// /// The "1. " prefix is 3 columns wide; continuation rows must indent to align /// with the item text, not the digit. #[test] fn fixture_ordered_list_has_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Ordered bullet spans start at char 0; continuation_indent encodes the // bullet width ("1. " = 3 cols, "10. " = 4 cols, etc.). assert!( map.values() .flatten() .any(|s| s.char_start == 0 && s.continuation_indent >= 3), "expected at least one ordered-list span with continuation_indent >= 3" ); } // --------------------------------------------------------------------------- // H1–H3 bottom border // --------------------------------------------------------------------------- /// H1 lines must have border_bottom set on at least one span. /// /// The fixture's first line is "# Heading One" (H1). The heading renderer /// draws a full-width underline after H1–H3 rows using the border_bottom field. #[test] fn fixture_h1_has_border_bottom() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Line 0 is "# Heading One". let h1_spans = map.get(&0).expect("line 0 (H1) must have decoration spans"); assert!( h1_spans.iter().any(|s| s.border_bottom.is_some()), "H1 (line 0) must have border_bottom set on at least one span" ); } // --------------------------------------------------------------------------- // Blank line inside fenced code block (regression #133) // --------------------------------------------------------------------------- /// A blank line that falls inside a fenced code block must still receive the /// fenced_bg full-line background, not lose it because the line is empty. /// /// Regression guard for #133: the original bug stripped fenced_bg from empty /// lines within a block because the span-emission path only ran when the line /// was non-empty. #[test] fn blank_line_inside_fenced_block_keeps_fenced_bg() { // The blank line (index 2) is inside the fenced block. let text = "```rust\nlet x = 1;\n\nlet y = 2;\n```\n"; let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let blank_line = map .get(&2) .expect("blank line inside fenced block must have spans"); assert!( blank_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "blank line inside fenced block must retain fenced_bg full_line_bg" ); } /// Regression: syntect fg spans must not be silently dropped. /// /// The original bug: a full-line background span (0..N) was emitted first. /// `split_into_spans` sorted by char_start and advanced char_pos to N in one /// step, then clipped every subsequent fg span to zero length and skipped it — /// producing a plain fenced_bg block with no syntax colours at all. /// /// The fix: emit fg spans directly (no separate background span); put /// `full_line_bg` on the first fg span so the background still fills the column. #[test] fn highlighted_block_spans_are_not_swallowed_by_background() { let text = "```rust\nlet keyword = 1;\n```\n"; let theme = crate::Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache)); let content_line = map.get(&1).expect("line 1 (code content) must have spans"); // Must have MORE than one span — the bug produced exactly one wide span // (the background) and nothing else. assert!( content_line.len() > 1, "highlighted line must have multiple spans (keyword + other tokens), got: {:?}", content_line.len() ); // At least one span must have a syntect-sourced Rgb foreground. assert!( content_line .iter() .any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _)))), "highlighted line must have at least one Color::Rgb fg span — \ background span must not swallow the syntect fg spans" ); // The first span (char 0) must carry full_line_bg for background fill. let first = content_line .iter() .min_by_key(|s| s.char_start) .expect("must have at least one span"); assert_eq!( first.full_line_bg, Some(theme.fenced_bg), "first highlighted span must carry full_line_bg for background fill" ); }