mod common;
use common::{init_test_logging, log_test_context, test_phase};
use rich_rust::r#box::SQUARE;
use rich_rust::color::ColorSystem;
use rich_rust::prelude::*;
use rich_rust::segment::{ControlCode, ControlType};
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
#[test]
fn regression_parsing_empty_string() {
init_test_logging();
log_test_context(
"regression_parsing_empty_string",
"Ensures empty string produces null style",
);
let _phase = test_phase("parse_empty");
let result = Style::parse("");
assert!(result.is_ok(), "Empty string should parse successfully");
let style = result.unwrap();
assert!(style.is_null(), "Empty string should produce null style");
tracing::info!("Regression test PASSED: empty string -> null style");
}
#[test]
fn regression_parsing_none_keyword() {
init_test_logging();
log_test_context(
"regression_parsing_none_keyword",
"Ensures 'none' keyword produces null style",
);
let _phase = test_phase("parse_none");
let result = Style::parse("none");
assert!(result.is_ok(), "'none' should parse successfully");
let style = result.unwrap();
assert!(style.is_null(), "'none' should produce null style");
tracing::info!("Regression test PASSED: 'none' -> null style");
}
#[test]
fn regression_parsing_hex_edge_cases() {
init_test_logging();
log_test_context(
"regression_parsing_hex_edge_cases",
"Ensures hex color parsing handles edge cases",
);
let _phase = test_phase("hex_edge_cases");
let valid_cases = ["#ff0000", "#00FF00", "#0000ff", "#AbCdEf"];
for hex in valid_cases {
let result = Color::parse(hex);
assert!(result.is_ok(), "'{hex}' should parse successfully");
tracing::debug!(hex = hex, "Valid hex parsed");
}
let invalid_cases = ["#ff", "#gggggg", "#12345", "#"];
for hex in invalid_cases {
let result = Color::parse(hex);
assert!(result.is_err(), "'{hex}' should fail to parse");
tracing::debug!(hex = hex, "Invalid hex correctly rejected");
}
tracing::info!("Regression test PASSED: hex color edge cases");
}
#[test]
fn regression_parsing_rgb_out_of_range() {
init_test_logging();
log_test_context(
"regression_parsing_rgb_out_of_range",
"Ensures RGB parsing validates ranges",
);
let _phase = test_phase("rgb_ranges");
let valid = Color::parse("rgb(255,255,255)");
assert!(valid.is_ok(), "rgb(255,255,255) should be valid");
let valid_zero = Color::parse("rgb(0,0,0)");
assert!(valid_zero.is_ok(), "rgb(0,0,0) should be valid");
let result = Color::parse("rgb(256,0,0)");
assert!(result.is_err(), "rgb(256,0,0) should fail (red > 255)");
let result = Color::parse("rgb(0,256,0)");
assert!(result.is_err(), "rgb(0,256,0) should fail (green > 255)");
let result = Color::parse("rgb(0,0,256)");
assert!(result.is_err(), "rgb(0,0,256) should fail (blue > 255)");
tracing::info!("Regression test PASSED: RGB range validation");
}
#[test]
fn regression_parsing_256_palette_range() {
init_test_logging();
log_test_context(
"regression_parsing_256_palette_range",
"Ensures 256-color palette validates index",
);
let _phase = test_phase("palette_range");
let valid_0 = Color::parse("color(0)");
assert!(valid_0.is_ok(), "color(0) should be valid");
let valid_255 = Color::parse("color(255)");
assert!(valid_255.is_ok(), "color(255) should be valid");
let invalid = Color::parse("color(256)");
assert!(invalid.is_err(), "color(256) should fail (> 255)");
let negative = Color::parse("color(-1)");
assert!(negative.is_err(), "color(-1) should fail (negative)");
tracing::info!("Regression test PASSED: 256-color palette range");
}
#[test]
fn regression_parsing_incomplete_keywords() {
init_test_logging();
log_test_context(
"regression_parsing_incomplete_keywords",
"Ensures incomplete keywords produce errors",
);
let _phase = test_phase("incomplete_keywords");
let on_result = Style::parse("on");
assert!(on_result.is_err(), "'on' alone should fail");
let not_result = Style::parse("not");
assert!(not_result.is_err(), "'not' alone should fail");
let link_result = Style::parse("link");
assert!(link_result.is_err(), "'link' alone should fail");
tracing::info!("Regression test PASSED: incomplete keywords");
}
#[test]
fn regression_parsing_whitespace_handling() {
init_test_logging();
log_test_context(
"regression_parsing_whitespace_handling",
"Ensures whitespace is handled correctly",
);
let _phase = test_phase("whitespace");
let cases = [
"bold",
" bold",
"bold ",
" bold ",
" bold ",
"bold red",
"bold red",
" bold red ",
"bold red on blue",
];
for case in cases {
let result = Style::parse(case);
assert!(result.is_ok(), "'{case}' should parse despite whitespace");
tracing::debug!(input = case, "Whitespace case passed");
}
tracing::info!("Regression test PASSED: whitespace handling");
}
#[test]
fn regression_parsing_case_insensitivity() {
init_test_logging();
log_test_context(
"regression_parsing_case_insensitivity",
"Ensures case insensitivity",
);
let _phase = test_phase("case");
let cases = ["BOLD", "Bold", "bold", "BOLD RED", "Bold Red", "bold red"];
for case in cases {
let result = Style::parse(case);
assert!(result.is_ok(), "'{case}' should parse (case insensitive)");
let style = result.unwrap();
assert!(
style.attributes.contains(Attributes::BOLD),
"'{case}' should set bold attribute"
);
}
tracing::info!("Regression test PASSED: case insensitivity");
}
#[test]
fn regression_parsing_named_colors() {
init_test_logging();
log_test_context(
"regression_parsing_named_colors",
"Ensures named colors are recognized",
);
let _phase = test_phase("named_colors");
let named_colors = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
];
for name in named_colors {
let result = Color::parse(name);
assert!(result.is_ok(), "'{name}' should be a valid named color");
tracing::debug!(color = name, "Named color parsed");
}
tracing::info!("Regression test PASSED: named colors");
}
#[test]
fn regression_layout_table_collapse_widths_rounding() {
init_test_logging();
log_test_context(
"regression_layout_table_collapse_widths_rounding",
"Bug: Missing rounding correction in collapse_widths()",
);
let _phase = test_phase("collapse_rounding");
tracing::info!("Regression test: collapse_widths() rounding correction (commit e160e4f)");
let wide_content = "X".repeat(45);
let mut table = Table::new()
.box_style(&SQUARE)
.padding(0, 0)
.with_column(Column::new("Col1").min_width(10))
.with_column(Column::new("Col2").min_width(10))
.with_column(Column::new("Col3").min_width(10));
table.add_row_cells([
wide_content.as_str(),
wide_content.as_str(),
wide_content.as_str(),
]);
let output = table.render_plain(100);
assert!(output.contains("Col1"), "Missing header content");
let first_line = output.lines().next().expect("should have lines");
assert!(
first_line.starts_with('┌'),
"Table should have proper border"
);
tracing::info!("Regression test PASSED: collapse_widths rounding correction");
}
#[test]
fn regression_layout_table_expand_widths_ratio() {
init_test_logging();
log_test_context(
"regression_layout_table_expand_widths_ratio",
"Ensures ratio-based expansion works correctly",
);
let _phase = test_phase("expand_ratio");
let mut table = Table::new()
.expand(true)
.box_style(&SQUARE)
.padding(0, 0)
.with_column(Column::new("A").ratio(1))
.with_column(Column::new("B").ratio(2))
.with_column(Column::new("C").ratio(1));
table.add_row_cells(["x", "y", "z"]);
let output = table.render_plain(80);
assert!(output.contains("A"), "Missing header content");
assert!(output.contains('x'), "Missing row values");
let lines: Vec<&str> = output.lines().collect();
assert!(lines.len() >= 3, "Table should have multiple lines");
tracing::info!("Regression test PASSED: expand_widths ratio distribution");
}
#[test]
fn regression_layout_table_empty_content() {
init_test_logging();
log_test_context(
"regression_layout_table_empty_content",
"Ensures empty content is handled",
);
let _phase = test_phase("empty_content");
let mut table = Table::new()
.with_column(Column::new("A"))
.with_column(Column::new("B"));
table.add_row_cells(["", ""]);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| table.render_plain(40)));
assert!(result.is_ok(), "Empty content should not panic");
tracing::info!("Regression test PASSED: empty content handling");
}
#[test]
fn regression_layout_table_width_sum_exactness() {
init_test_logging();
log_test_context(
"regression_layout_table_width_sum_exactness",
"Ensures column widths sum exactly",
);
let _phase = test_phase("width_sum");
let mut table = Table::new()
.expand(true)
.box_style(&SQUARE)
.padding(0, 0)
.with_column(Column::new("A").ratio(7))
.with_column(Column::new("B").ratio(13))
.with_column(Column::new("C").ratio(23));
table.add_row_cells(["x", "y", "z"]);
let output = table.render_plain(100);
assert!(output.contains("A"), "Missing header content");
tracing::info!("Regression test PASSED: width sum exactness");
}
#[test]
fn regression_layout_table_collapse_padding_width_respected() {
use rich_rust::cells;
init_test_logging();
log_test_context(
"regression_layout_table_collapse_padding_width_respected",
"Ensures collapse_padding keeps lines within max_width",
);
let _phase = test_phase("collapse_padding_width");
let mut table = Table::new()
.padding(1, 0)
.collapse_padding(true)
.pad_edge(false)
.with_column(Column::new("A").width(1))
.with_column(Column::new("B").width(1));
table.add_row_cells(["1", "2"]);
let max_width = 6; let output = table.render_plain(max_width);
for line in output.lines().filter(|line| !line.is_empty()) {
assert!(
cells::cell_len(line) <= max_width,
"line exceeds max width with collapse_padding"
);
}
tracing::info!("Regression test PASSED: collapse_padding width respected");
}
#[test]
fn regression_layout_table_very_narrow_width() {
init_test_logging();
log_test_context(
"regression_layout_table_very_narrow_width",
"Ensures narrow width is handled",
);
let _phase = test_phase("narrow_width");
let mut table = Table::new()
.with_column(Column::new("Name"))
.with_column(Column::new("Value"));
table.add_row_cells(["Test", "Data"]);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| table.render_plain(10)));
assert!(result.is_ok(), "Narrow width should not panic");
tracing::info!("Regression test PASSED: narrow width handling");
}
#[test]
fn regression_layout_panel_multiline_content() {
init_test_logging();
log_test_context(
"regression_layout_panel_multiline_content",
"Ensures multiline panel content works",
);
let _phase = test_phase("multiline_panel");
let content = "Line 1\nLine 2\nLine 3";
let panel = Panel::from_text(content).title("Test").width(30);
let segments = panel.render(40);
let output: String = segments.into_iter().map(|s| s.text).collect();
let lines: Vec<&str> = output.lines().collect();
assert!(
lines.len() >= 5,
"Panel should have header, 3 content lines, and footer"
);
tracing::info!("Regression test PASSED: multiline panel content");
}
#[test]
fn regression_layout_rule_long_title() {
init_test_logging();
log_test_context(
"regression_layout_rule_long_title",
"Ensures long titles are handled",
);
let _phase = test_phase("long_title");
let long_title = "This is a very long title that exceeds the available width";
let rule = Rule::with_title(long_title);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rule.render(30)));
assert!(result.is_ok(), "Long title should not panic");
tracing::info!("Regression test PASSED: long title handling");
}
#[test]
fn regression_layout_tree_deep_nesting() {
init_test_logging();
log_test_context(
"regression_layout_tree_deep_nesting",
"Ensures deep nesting works",
);
let _phase = test_phase("deep_tree");
let mut deepest = TreeNode::new("Level 10");
for i in (1..10).rev() {
let mut parent = TreeNode::new(format!("Level {i}"));
parent = parent.child(deepest);
deepest = parent;
}
let tree = Tree::new(deepest);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tree.render()));
assert!(result.is_ok(), "Deep nesting should not panic");
tracing::info!("Regression test PASSED: deep tree nesting");
}
#[test]
fn regression_rendering_hyperlink_only_style() {
init_test_logging();
log_test_context(
"regression_rendering_hyperlink_only_style",
"Bug: Hyperlink-only styles not rendering OSC 8 sequences",
);
let _phase = test_phase("hyperlink_only");
tracing::info!("Regression test: hyperlink-only style rendering (commit ca4bd56)");
let mut style = Style::new();
style.link = Some("https://example.com".to_string());
assert!(!style.is_null(), "Style with link should not be null");
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
assert!(
prefix.contains("\x1b]8;;") || prefix.contains("\x1b]8;"),
"Hyperlink-only style should render OSC 8 prefix: got '{}'",
prefix.escape_debug()
);
assert!(
suffix.contains("\x1b]8;;") || suffix.contains("\x1b]8;"),
"Hyperlink-only style should render OSC 8 suffix: got '{}'",
suffix.escape_debug()
);
tracing::info!("Regression test PASSED: hyperlink-only style renders OSC 8");
}
#[test]
fn regression_rendering_hyperlink_with_attributes() {
init_test_logging();
log_test_context(
"regression_rendering_hyperlink_with_attributes",
"Ensures hyperlink + attributes both render",
);
let _phase = test_phase("hyperlink_with_attrs");
let style = Style::new().bold().link("https://example.com");
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, _suffix) = &*ansi;
assert!(
prefix.contains("\x1b[1m") || prefix.contains(";1m") || prefix.contains("\x1b[1;"),
"Should render bold attribute: got '{}'",
prefix.escape_debug()
);
assert!(
prefix.contains("\x1b]8;"),
"Should render hyperlink OSC 8 sequence: got '{}'",
prefix.escape_debug()
);
tracing::info!("Regression test PASSED: hyperlink + attributes");
}
#[test]
fn regression_rendering_style_combine_preserves_hyperlink() {
init_test_logging();
log_test_context(
"regression_rendering_style_combine_preserves_hyperlink",
"Ensures style combination preserves hyperlinks",
);
let _phase = test_phase("combine_hyperlink");
let style1 = Style::new().bold();
let style2 = Style::new().link("https://example.com");
let combined = style1.combine(&style2);
assert!(
combined.attributes.contains(Attributes::BOLD),
"Combined should have bold"
);
assert!(combined.link.is_some(), "Combined should have hyperlink");
assert_eq!(
combined.link.as_deref(),
Some("https://example.com"),
"Hyperlink URL should be preserved"
);
tracing::info!("Regression test PASSED: style combine preserves hyperlink");
}
#[test]
fn regression_rendering_null_style() {
init_test_logging();
log_test_context(
"regression_rendering_null_style",
"Ensures null style renders as empty",
);
let _phase = test_phase("null_style");
let style = Style::null();
assert!(style.is_null(), "Style::null() should be null");
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
assert!(prefix.is_empty(), "Null style prefix should be empty");
assert!(suffix.is_empty(), "Null style suffix should be empty");
tracing::info!("Regression test PASSED: null style renders empty");
}
#[test]
fn regression_rendering_color_downgrade_truecolor_to_256() {
init_test_logging();
log_test_context(
"regression_rendering_color_downgrade_truecolor_to_256",
"Ensures color downgrade works",
);
let _phase = test_phase("color_downgrade");
let style = Style::parse("#ff5500").unwrap();
let ansi = style.render_ansi(ColorSystem::EightBit);
let (prefix, _suffix) = &*ansi;
assert!(
prefix.contains("38;5;"),
"Should downgrade to 256-color format: got '{}'",
prefix.escape_debug()
);
tracing::info!("Regression test PASSED: color downgrade");
}
#[test]
fn regression_rendering_unicode_cjk_width() {
init_test_logging();
log_test_context(
"regression_rendering_unicode_cjk_width",
"Ensures CJK characters have width 2",
);
let _phase = test_phase("cjk_width");
use rich_rust::cells::cell_len;
let cjk_chars = ['ä¸', 'æ–‡', 'æ—¥', '本', '語'];
for ch in cjk_chars {
let s = ch.to_string();
let width = cell_len(&s);
assert_eq!(
width, 2,
"CJK character '{}' should have width 2, got {}",
ch, width
);
}
let ascii = "hello";
assert_eq!(cell_len(ascii), 5, "ASCII 'hello' should have width 5");
tracing::info!("Regression test PASSED: CJK character width");
}
#[test]
fn regression_rendering_unicode_emoji_width() {
init_test_logging();
log_test_context(
"regression_rendering_unicode_emoji_width",
"Ensures emoji have correct width",
);
let _phase = test_phase("emoji_width");
use rich_rust::cells::cell_len;
let emoji = "\u{1F600}";
let width = cell_len(emoji);
assert!(
width == 1 || width == 2,
"Emoji should have width 1 or 2, got {}",
width
);
tracing::info!("Regression test PASSED: emoji width");
}
#[test]
fn regression_rendering_segment_split_cjk() {
init_test_logging();
log_test_context(
"regression_rendering_segment_split_cjk",
"Ensures segment split preserves CJK characters",
);
let _phase = test_phase("segment_split");
let segment = Segment::new("䏿–‡", None);
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| segment.split_at_cell(2)));
assert!(result.is_ok(), "Segment split should not panic");
let (left, right) = result.unwrap();
assert_eq!(left.text.as_ref(), "ä¸");
assert_eq!(right.text.as_ref(), "æ–‡");
assert_eq!(format!("{}{}", left.text, right.text), "䏿–‡");
tracing::info!("Regression test PASSED: segment split with CJK");
}
#[test]
fn regression_rendering_ansi_strip_completeness() {
init_test_logging();
log_test_context(
"regression_rendering_ansi_strip_completeness",
"Ensures ANSI stripping is complete",
);
let _phase = test_phase("ansi_strip");
let style = Style::parse("bold red on blue").unwrap();
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
let styled = format!("{prefix}Hello{suffix}");
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let stripped = ansi_regex.replace_all(&styled, "");
assert_eq!(stripped, "Hello", "ANSI stripping should leave only text");
tracing::info!("Regression test PASSED: ANSI strip completeness");
}
#[test]
fn regression_rendering_control_character_width() {
init_test_logging();
log_test_context(
"regression_rendering_control_character_width",
"Ensures control characters have zero width",
);
let _phase = test_phase("control_chars");
use rich_rust::cells::cell_len;
let control_chars = ['\x00', '\x01', '\x1f', '\x7f'];
for ch in control_chars {
let s = ch.to_string();
let width = cell_len(&s);
assert_eq!(
width, 0,
"Control character {:?} should have width 0, got {}",
ch, width
);
}
tracing::info!("Regression test PASSED: control character width");
}
#[test]
fn regression_rule_title_truncation() {
init_test_logging();
log_test_context(
"regression_rule_title_truncation",
"Ensures rule titles are truncated to width",
);
let _phase = test_phase("rule_truncation");
use rich_rust::cells;
let rule = Rule::with_title("abcdefghijk");
let width = 10;
let output = rule.render_plain(width);
let trimmed = output.trim_end_matches('\n');
assert_eq!(
cells::cell_len(trimmed),
width,
"Rule output should be truncated to width"
);
assert!(
!trimmed.contains('\u{2500}'),
"Truncated title should not include rule characters"
);
tracing::info!("Regression test PASSED: rule title truncation");
}
#[test]
fn regression_rule_title_exact_width_no_rule_chars() {
init_test_logging();
log_test_context(
"regression_rule_title_exact_width_no_rule_chars",
"Ensures exact-width titles omit rule characters",
);
let _phase = test_phase("rule_exact_width");
let rule = Rule::with_title("abcd");
let width = 6; let output = rule.render_plain(width);
let trimmed = output.trim_end_matches('\n');
assert_eq!(
trimmed, " abcd ",
"Rule should render title with surrounding spaces"
);
assert!(
!trimmed.contains('\u{2500}'),
"No rule characters expected when width is exact"
);
tracing::info!("Regression test PASSED: rule exact width");
}
#[test]
fn regression_table_title_preserves_spans() {
init_test_logging();
log_test_context(
"regression_table_title_preserves_spans",
"Ensures table titles preserve span styles",
);
let _phase = test_phase("table_title_spans");
let mut title = Text::new("Title");
title.stylize(0, 5, Style::new().bold());
let mut table = Table::new()
.with_column(Column::new("Col"))
.title(title)
.title_style(Style::new().color(Color::parse("red").unwrap()));
table.add_row_cells(["1"]);
let segments = table.render(30);
let has_bold_red = segments.iter().any(|seg| {
seg.text.contains("Title")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::BOLD))
&& seg
.style
.as_ref()
.is_some_and(|style| style.color.is_some())
});
assert!(has_bold_red, "title should preserve bold + color style");
tracing::info!("Regression test PASSED: table title spans preserved");
}
#[test]
fn regression_tree_label_preserves_spans() {
init_test_logging();
log_test_context(
"regression_tree_label_preserves_spans",
"Ensures tree labels preserve span styles",
);
let _phase = test_phase("tree_label_spans");
let mut label = Text::new("root");
label.stylize(0, 4, Style::new().bold());
let tree = Tree::new(TreeNode::new(label));
let segments = tree.render();
let has_bold = segments.iter().any(|seg| {
seg.text.contains("root")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::BOLD))
});
assert!(has_bold, "tree label should preserve bold style");
tracing::info!("Regression test PASSED: tree label spans preserved");
}
#[test]
fn regression_panel_title_truncates_and_fits_width() {
init_test_logging();
log_test_context(
"regression_panel_title_truncates_and_fits_width",
"Ensures panel titles truncate and fit width",
);
let _phase = test_phase("panel_title_truncate");
use rich_rust::cells;
let panel = Panel::from_text("Body").title("ABCDEFGHIJK").width(10);
let output: String = panel.render(10).into_iter().map(|seg| seg.text).collect();
assert!(
output.contains("..."),
"expected ellipsis in truncated title"
);
for line in output.lines().filter(|line| !line.is_empty()) {
assert!(
cells::cell_len(line) <= 10,
"panel line should not exceed width"
);
}
tracing::info!("Regression test PASSED: panel title truncation");
}
#[test]
fn regression_panel_subtitle_truncates_and_fits_width() {
init_test_logging();
log_test_context(
"regression_panel_subtitle_truncates_and_fits_width",
"Ensures panel subtitles truncate and fit width",
);
let _phase = test_phase("panel_subtitle_truncate");
use rich_rust::cells;
let panel = Panel::from_text("Body")
.subtitle("LongSubtitleHere")
.width(12);
let output: String = panel.render(12).into_iter().map(|seg| seg.text).collect();
assert!(
output.contains("..."),
"expected ellipsis in truncated subtitle"
);
for line in output.lines().filter(|line| !line.is_empty()) {
assert!(
cells::cell_len(line) <= 12,
"panel line should not exceed width"
);
}
tracing::info!("Regression test PASSED: panel subtitle truncation");
}
#[test]
fn regression_panel_title_preserves_spans() {
init_test_logging();
log_test_context(
"regression_panel_title_preserves_spans",
"Ensures panel title preserves span styles",
);
let _phase = test_phase("panel_title_spans");
let mut title = Text::new("TitleSpan");
title.stylize(0, 2, Style::new().italic());
let panel = Panel::from_text("Body").title(title).width(20);
let segments = panel.render(20);
let has_italic = segments.iter().any(|seg| {
seg.text.contains("Ti")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::ITALIC))
});
assert!(has_italic, "title should preserve italic span");
tracing::info!("Regression test PASSED: panel title spans preserved");
}
#[test]
fn regression_panel_subtitle_preserves_spans() {
init_test_logging();
log_test_context(
"regression_panel_subtitle_preserves_spans",
"Ensures panel subtitle preserves span styles",
);
let _phase = test_phase("panel_subtitle_spans");
let mut subtitle = Text::new("SubSpan");
subtitle.stylize(0, 3, Style::new().underline());
let panel = Panel::from_text("Body").subtitle(subtitle).width(24);
let segments = panel.render(24);
let has_underline = segments.iter().any(|seg| {
seg.text.contains("Sub")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::UNDERLINE))
});
assert!(has_underline, "subtitle should preserve underline span");
tracing::info!("Regression test PASSED: panel subtitle spans preserved");
}
#[test]
fn regression_table_cell_preserves_spans() {
init_test_logging();
log_test_context(
"regression_table_cell_preserves_spans",
"Ensures table cell Text spans are preserved",
);
let _phase = test_phase("table_cell_spans");
let mut cell_text = Text::new("Cell");
cell_text.stylize(0, 4, Style::new().italic());
let mut table = Table::new().with_column(Column::new("H"));
table.add_row_cells([cell_text]);
let segments = table.render(20);
let has_italic = segments.iter().any(|seg| {
seg.text.contains("Cell")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::ITALIC))
});
assert!(has_italic, "cell should preserve italic style");
tracing::info!("Regression test PASSED: table cell spans preserved");
}
#[test]
fn regression_table_caption_preserves_spans() {
init_test_logging();
log_test_context(
"regression_table_caption_preserves_spans",
"Ensures table caption preserves span styles",
);
let _phase = test_phase("table_caption_spans");
let mut caption = Text::new("Caption");
caption.stylize(0, 7, Style::new().underline());
let mut table = Table::new()
.with_column(Column::new("H"))
.caption(caption)
.caption_style(Style::new().color(Color::parse("green").unwrap()));
table.add_row_cells(["1"]);
let segments = table.render(30);
let has_underline = segments.iter().any(|seg| {
seg.text.contains("Cap")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::UNDERLINE))
});
assert!(has_underline, "caption should preserve underline span");
tracing::info!("Regression test PASSED: table caption spans preserved");
}
#[test]
fn regression_table_caption_alignment_width() {
init_test_logging();
log_test_context(
"regression_table_caption_alignment_width",
"Ensures caption alignment keeps line width consistent",
);
let _phase = test_phase("table_caption_align_width");
use rich_rust::cells;
let width = 24;
let base = Table::new()
.with_column(Column::new("H"))
.with_row_cells(["1"])
.caption("Caption");
let tables = [
base.clone().caption_justify(JustifyMethod::Left),
base.clone().caption_justify(JustifyMethod::Center),
base.caption_justify(JustifyMethod::Right),
];
for table in tables {
let output: String = table
.render(width)
.into_iter()
.map(|seg| seg.text)
.collect();
let mut lines = output.lines().filter(|line| !line.is_empty());
let first_line = lines
.next()
.expect("table output should have at least one line");
let table_width = cells::cell_len(first_line);
let caption_line = lines
.next_back()
.expect("table output should include a caption line");
assert_eq!(
cells::cell_len(caption_line),
table_width,
"caption line should match table width"
);
}
tracing::info!("Regression test PASSED: table caption alignment width");
}
#[test]
fn regression_table_leading_inserts_blank_lines() {
init_test_logging();
log_test_context(
"regression_table_leading_inserts_blank_lines",
"Ensures table leading inserts blank spacer lines between rows",
);
let _phase = test_phase("table_leading_blank_lines");
use rich_rust::cells;
let table = Table::new()
.with_column(Column::new("H"))
.show_header(false)
.leading(2)
.with_row_cells(["row-1"])
.with_row_cells(["row-2"]);
let output: String = table.render(30).into_iter().map(|seg| seg.text).collect();
let lines: Vec<&str> = output.lines().filter(|line| !line.is_empty()).collect();
let first_idx = lines
.iter()
.position(|line| line.contains("row-1"))
.expect("row-1 line should exist");
let second_idx = lines
.iter()
.position(|line| line.contains("row-2"))
.expect("row-2 line should exist");
assert_eq!(
second_idx.saturating_sub(first_idx).saturating_sub(1),
2,
"leading should insert two blank lines between rows"
);
let table_width = cells::cell_len(lines[0]);
for line in lines {
assert_eq!(
cells::cell_len(line),
table_width,
"table line should match table width"
);
}
tracing::info!("Regression test PASSED: table leading blank lines");
}
#[test]
fn regression_table_vertical_padding_inserts_blank_lines() {
init_test_logging();
log_test_context(
"regression_table_vertical_padding_inserts_blank_lines",
"Ensures vertical padding adds spacer lines around rows",
);
let _phase = test_phase("table_vertical_padding");
use rich_rust::cells;
let table = Table::new()
.with_column(Column::new("H"))
.show_header(false)
.padding(1, 1)
.with_row_cells(["row-1"])
.with_row_cells(["row-2"]);
let output: String = table.render(30).into_iter().map(|seg| seg.text).collect();
let lines: Vec<&str> = output.lines().filter(|line| !line.is_empty()).collect();
let first_idx = lines
.iter()
.position(|line| line.contains("row-1"))
.expect("row-1 line should exist");
let second_idx = lines
.iter()
.position(|line| line.contains("row-2"))
.expect("row-2 line should exist");
assert_eq!(
second_idx.saturating_sub(first_idx).saturating_sub(1),
2,
"vertical padding should add two blank lines between rows"
);
let table_width = cells::cell_len(lines[0]);
for line in lines {
assert_eq!(
cells::cell_len(line),
table_width,
"table line should match table width"
);
}
tracing::info!("Regression test PASSED: table vertical padding");
}
#[test]
fn regression_rule_title_alignment_width_consistency() {
init_test_logging();
log_test_context(
"regression_rule_title_alignment_width_consistency",
"Ensures rule output width matches requested width",
);
let _phase = test_phase("rule_title_width");
use rich_rust::cells;
let width = 20;
let rule_left = Rule::with_title("Title").align_left();
let rule_center = Rule::with_title("Title").align_center();
let rule_right = Rule::with_title("Title").align_right();
for rule in [rule_left, rule_center, rule_right] {
let output = rule.render_plain(width);
for line in output.lines().filter(|line| !line.is_empty()) {
assert_eq!(cells::cell_len(line), width, "rule line should match width");
}
}
tracing::info!("Regression test PASSED: rule title width consistency");
}
#[test]
fn regression_tree_highlight_combines_with_spans() {
init_test_logging();
log_test_context(
"regression_tree_highlight_combines_with_spans",
"Ensures highlight style combines with span styles",
);
let _phase = test_phase("tree_highlight_span");
let mut label = Text::new("root");
label.stylize(0, 4, Style::new().bold());
let tree = Tree::new(TreeNode::new(label))
.highlight_style(Style::new().color(Color::parse("red").unwrap()).italic());
let segments = tree.render();
let has_bold_red = segments.iter().any(|seg| {
seg.text.contains("root")
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::BOLD))
&& seg
.style
.as_ref()
.is_some_and(|style| style.color.is_some())
&& seg
.style
.as_ref()
.is_some_and(|style| style.attributes.contains(Attributes::ITALIC))
});
assert!(
has_bold_red,
"highlight should combine color/italic with bold span"
);
tracing::info!("Regression test PASSED: tree highlight combines with spans");
}
#[test]
fn regression_console_control_segments_emit_sequences() {
init_test_logging();
log_test_context(
"regression_console_control_segments_emit_sequences",
"Ensures control segments are emitted in order",
);
let _phase = test_phase("control_segments");
#[derive(Clone)]
struct SharedBuffer {
inner: Arc<Mutex<Vec<u8>>>,
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut guard = self.inner.lock().expect("buffer lock poisoned");
guard.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let buffer = Arc::new(Mutex::new(Vec::new()));
let writer = Box::new(SharedBuffer {
inner: Arc::clone(&buffer),
});
let console = Console::builder().file(writer).build();
let segments = vec![
Segment::control(vec![ControlCode::new(ControlType::Bell)]),
Segment::control(vec![ControlCode::with_params_vec(
ControlType::CursorUp,
vec![2],
)]),
Segment::control(vec![ControlCode::with_params_vec(
ControlType::CursorMoveTo,
vec![3, 4],
)]),
Segment::control(vec![ControlCode::with_params_vec(
ControlType::EraseInLine,
vec![2],
)]),
];
console.print_segments(&segments);
let output = String::from_utf8(buffer.lock().expect("buffer lock poisoned").clone())
.expect("output should be valid UTF-8");
let expected = "\x07\x1b[2A\x1b[5;4H\x1b[2K";
assert_eq!(
output, expected,
"Control sequence output should match expected ANSI codes"
);
tracing::info!("Regression test PASSED: control segments emit sequences");
}
#[test]
fn regression_console_control_set_window_title() {
init_test_logging();
log_test_context(
"regression_console_control_set_window_title",
"Ensures SetWindowTitle emits OSC title sequence",
);
let _phase = test_phase("control_title");
#[derive(Clone)]
struct SharedBuffer {
inner: Arc<Mutex<Vec<u8>>>,
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut guard = self.inner.lock().expect("buffer lock poisoned");
guard.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let buffer = Arc::new(Mutex::new(Vec::new()));
let writer = Box::new(SharedBuffer {
inner: Arc::clone(&buffer),
});
let console = Console::builder().file(writer).build();
let segment = Segment {
text: "rich_rust".to_string().into(),
style: None,
control: Some(vec![ControlCode::new(ControlType::SetWindowTitle)]),
};
console.print_segments(&[segment]);
let output = String::from_utf8(buffer.lock().expect("buffer lock poisoned").clone())
.expect("output should be valid UTF-8");
let expected = "\x1b]0;rich_rust\x07";
assert_eq!(output, expected, "Window title OSC sequence should match");
tracing::info!("Regression test PASSED: set window title control");
}
#[test]
fn regression_rendering_hyperlink_with_attributes_correctness() {
init_test_logging();
log_test_context(
"regression_rendering_hyperlink_with_attributes_correctness",
"Ensures hyperlink + attributes render correctly",
);
let _phase = test_phase("hyperlink_with_attrs_correctness");
let style = Style::new().bold().link("https://example.com");
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
assert!(
prefix.contains("\x1b[1m") || prefix.contains(";1m") || prefix.contains("\x1b[1;"),
"Should render bold attribute: got '{}'",
prefix.escape_debug()
);
assert!(
prefix.contains("\x1b]8;"),
"Should render hyperlink OSC 8 sequence: got '{}'",
prefix.escape_debug()
);
assert!(
suffix.contains("\x1b]8;;") || suffix.contains("\x1b]8;"),
"Hyperlink-only style should render OSC 8 suffix: got '{}'",
suffix.escape_debug()
);
tracing::info!("Regression test PASSED: hyperlink + attributes");
}
#[test]
fn regression_rendering_hyperlink_only_style_correctness() {
init_test_logging();
log_test_context(
"regression_rendering_hyperlink_only_style_correctness",
"Ensures hyperlink-only styles render correctly",
);
let _phase = test_phase("hyperlink_only_correctness");
let mut style = Style::new();
style.link = Some("https://example.com".to_string());
assert!(!style.is_null(), "Style with link should not be null");
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
assert!(
prefix.contains("\x1b]8;;") || prefix.contains("\x1b]8;"),
"Hyperlink-only style should render OSC 8 prefix: got '{}'",
prefix.escape_debug()
);
assert!(
suffix.contains("\x1b]8;;") || suffix.contains("\x1b]8;"),
"Hyperlink-only style should render OSC 8 suffix: got '{}'",
suffix.escape_debug()
);
tracing::info!("Regression test PASSED: hyperlink-only style renders OSC 8");
}
#[test]
fn regression_rendering_style_combine_preserves_hyperlink_correctness() {
init_test_logging();
log_test_context(
"regression_rendering_style_combine_preserves_hyperlink_correctness",
"Ensures style combination preserves hyperlinks",
);
let _phase = test_phase("combine_hyperlink_correctness");
let s1 = Style::new().link("https://example.com");
let s2 = Style::new().bold();
let combined = s1.combine(&s2);
assert!(
combined.attributes.contains(Attributes::BOLD),
"Combined should have bold"
);
assert!(combined.link.is_some(), "Combined should have hyperlink");
assert_eq!(
combined.link.as_deref(),
Some("https://example.com"),
"Hyperlink URL should be preserved"
);
let style = Style::new().bold().link("https://example.com");
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
assert!(prefix.contains("\x1b]8;;https://example.com\x1b\\"));
assert!(prefix.contains("\x1b[1m"));
assert!(suffix.contains("\x1b]8;;\x1b\\"));
assert!(suffix.contains("\x1b[0m"));
tracing::info!("Regression test PASSED: style combine preserves hyperlink");
}
#[test]
fn regression_rendering_color_downgrade_truecolor_to_8bit_correctness() {
init_test_logging();
log_test_context(
"regression_rendering_color_downgrade_truecolor_to_8bit_correctness",
"Ensures color downgrade works correctly",
);
let _phase = test_phase("color_downgrade_correctness");
let color = Color::from_rgb(255, 0, 0); let style = Style::new().color(color);
let ansi = style.render_ansi(ColorSystem::EightBit);
let (prefix, _suffix) = &*ansi;
assert!(
prefix.contains("38;5;"),
"Should downgrade to 256-color format: got '{}'",
prefix.escape_debug()
);
tracing::info!("Regression test PASSED: color downgrade");
}
#[test]
fn regression_rendering_ansi_strip_completeness_correctness() {
init_test_logging();
log_test_context(
"regression_rendering_ansi_strip_completeness_correctness",
"Ensures ANSI stripping is complete",
);
let _phase = test_phase("ansi_strip_correctness");
let style = Style::parse("bold red on blue").unwrap();
let ansi = style.render_ansi(ColorSystem::TrueColor);
let (prefix, suffix) = &*ansi;
let styled = format!("{prefix}Hello{suffix}");
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let stripped = ansi_regex.replace_all(&styled, "");
assert_eq!(stripped, "Hello", "ANSI stripping should leave only text");
tracing::info!("Regression test PASSED: ANSI strip completeness");
}
#[test]
fn regression_rendering_control_character_width_correctness() {
init_test_logging();
log_test_context(
"regression_rendering_control_character_width_correctness",
"Ensures control characters have zero width",
);
let _phase = test_phase("control_chars_correctness");
use rich_rust::cells::cell_len;
let control_chars = ['\x00', '\x01', '\x1f', '\x7f'];
for ch in control_chars {
let s = ch.to_string();
let width = cell_len(&s);
assert_eq!(
width, 0,
"Control character {:?} should have width 0, got {}",
ch, width
);
}
tracing::info!("Regression test PASSED: control character width");
}