use proptest::prelude::*;
use rich_rust::color::{Color, ColorSystem, ColorTriplet, ColorType};
use rich_rust::measure::Measurement;
use rich_rust::segment::Segment;
use rich_rust::style::{Attributes, Style};
use rich_rust::text::Text;
fn rgb_triplet() -> impl Strategy<Value = (u8, u8, u8)> {
(any::<u8>(), any::<u8>(), any::<u8>())
}
fn ansi_color_number() -> impl Strategy<Value = u8> {
0u8..=255u8
}
fn random_attributes() -> impl Strategy<Value = Attributes> {
(0u16..8192u16).prop_map(Attributes::from_bits_truncate)
}
fn random_style() -> impl Strategy<Value = Style> {
(
prop::option::of(rgb_triplet()),
prop::option::of(rgb_triplet()),
random_attributes(),
prop::option::of("[a-z]{0,20}"),
)
.prop_map(|(fg, bg, attrs, link)| {
let mut style = Style::new();
if let Some((r, g, b)) = fg {
style = style.color(Color::from_rgb(r, g, b));
}
if let Some((r, g, b)) = bg {
style = style.bgcolor(Color::from_rgb(r, g, b));
}
if attrs.contains(Attributes::BOLD) {
style = style.bold();
}
if attrs.contains(Attributes::ITALIC) {
style = style.italic();
}
if attrs.contains(Attributes::UNDERLINE) {
style = style.underline();
}
if attrs.contains(Attributes::STRIKE) {
style = style.strike();
}
if let Some(url) = link
&& !url.is_empty()
{
style = style.link(url);
}
style
})
}
fn random_measurement() -> impl Strategy<Value = Measurement> {
(0usize..1000, 0usize..1000).prop_map(|(a, b)| Measurement::new(a, b))
}
fn ascii_text() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 ]{0,100}"
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn prop_color_rgb_roundtrip(r in any::<u8>(), g in any::<u8>(), b in any::<u8>()) {
let color = Color::from_rgb(r, g, b);
let triplet = color.get_truecolor();
prop_assert_eq!(triplet.red, r);
prop_assert_eq!(triplet.green, g);
prop_assert_eq!(triplet.blue, b);
}
#[test]
fn prop_color_hex_roundtrip(r in any::<u8>(), g in any::<u8>(), b in any::<u8>()) {
let hex = format!("#{r:02x}{g:02x}{b:02x}");
let color = Color::parse(&hex).expect("valid hex should parse");
let triplet = color.get_truecolor();
prop_assert_eq!(triplet.red, r);
prop_assert_eq!(triplet.green, g);
prop_assert_eq!(triplet.blue, b);
}
#[test]
fn prop_color_downgrade_monotonic(r in any::<u8>(), g in any::<u8>(), b in any::<u8>()) {
let truecolor = Color::from_rgb(r, g, b);
prop_assert_eq!(truecolor.color_type, ColorType::TrueColor);
let eightbit = truecolor.downgrade(ColorSystem::EightBit);
prop_assert!(
matches!(eightbit.color_type, ColorType::Standard | ColorType::EightBit),
"downgrade to 8-bit should be Standard or EightBit"
);
let standard = truecolor.downgrade(ColorSystem::Standard);
prop_assert!(
matches!(standard.color_type, ColorType::Standard),
"downgrade to standard should be Standard"
);
let standard_again = standard.downgrade(ColorSystem::Standard);
prop_assert_eq!(standard.color_type, standard_again.color_type);
}
#[test]
fn prop_color_standard_stable(n in 0u8..16u8) {
let color = Color::from_ansi(n);
prop_assert!(matches!(color.color_type, ColorType::Standard));
let downgraded = color.downgrade(ColorSystem::Standard);
prop_assert!(matches!(downgraded.color_type, ColorType::Standard));
}
#[test]
fn prop_color_standard_valid_codes(n in 0u8..16u8) {
let color = Color::from_ansi(n);
let fg_codes = color.get_ansi_codes(true);
let bg_codes = color.get_ansi_codes(false);
prop_assert!(!fg_codes.is_empty(), "foreground codes should not be empty");
prop_assert!(!bg_codes.is_empty(), "background codes should not be empty");
for code in &fg_codes {
let _: u32 = code.parse().expect("code should be numeric");
}
for code in &bg_codes {
let _: u32 = code.parse().expect("code should be numeric");
}
}
#[test]
fn prop_color_eightbit_valid_codes(n in ansi_color_number()) {
let color = Color::from_ansi(n);
let fg_codes = color.get_ansi_codes(true);
let bg_codes = color.get_ansi_codes(false);
prop_assert!(!fg_codes.is_empty());
prop_assert!(!bg_codes.is_empty());
}
#[test]
fn prop_color_truecolor_valid_codes((r, g, b) in rgb_triplet()) {
let color = Color::from_rgb(r, g, b);
let fg_codes = color.get_ansi_codes(true);
let bg_codes = color.get_ansi_codes(false);
prop_assert_eq!(fg_codes.len(), 5);
prop_assert_eq!(bg_codes.len(), 5);
prop_assert_eq!(&fg_codes[0], "38");
prop_assert_eq!(&bg_codes[0], "48");
prop_assert_eq!(&fg_codes[1], "2");
prop_assert_eq!(&bg_codes[1], "2");
}
#[test]
fn prop_colortriplet_hex_format((r, g, b) in rgb_triplet()) {
let triplet = ColorTriplet::new(r, g, b);
let hex = triplet.hex();
prop_assert_eq!(hex.len(), 7, "hex should be 7 chars");
prop_assert!(hex.starts_with('#'), "hex should start with #");
prop_assert!(hex[1..].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn prop_colortriplet_normalized_range((r, g, b) in rgb_triplet()) {
let triplet = ColorTriplet::new(r, g, b);
let (nr, ng, nb) = triplet.normalized();
prop_assert!((0.0..=1.0).contains(&nr));
prop_assert!((0.0..=1.0).contains(&ng));
prop_assert!((0.0..=1.0).contains(&nb));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn prop_style_null_left_identity(style in random_style()) {
let null = Style::null();
let combined = null.combine(&style);
prop_assert_eq!(combined.color, style.color);
prop_assert_eq!(combined.bgcolor, style.bgcolor);
prop_assert_eq!(combined.link, style.link);
}
#[test]
fn prop_style_null_right_identity(style in random_style()) {
let null = Style::null();
let combined = style.combine(&null);
prop_assert_eq!(combined.color, style.color);
prop_assert_eq!(combined.bgcolor, style.bgcolor);
prop_assert_eq!(combined.link, style.link);
}
#[test]
fn prop_style_null_combined_null(_n in 0..1i32) {
let null1 = Style::null();
let null2 = Style::null();
let combined = null1.combine(&null2);
prop_assert!(combined.is_null() || (combined.color.is_none() && combined.bgcolor.is_none()));
}
#[test]
fn prop_style_render_balanced(style in random_style(), text in ascii_text()) {
let rendered = style.render(&text, ColorSystem::TrueColor);
let sgr_starts = rendered.matches("\x1b[").count();
let sgr_resets = rendered.matches("\x1b[0m").count();
if !style.is_null() && !rendered.is_empty() && style.color.is_some() || style.bgcolor.is_some() {
prop_assert!(sgr_resets > 0 || sgr_starts == 0,
"non-null style with colors should reset or have no codes");
}
}
#[test]
fn prop_style_ansi_codes_format(style in random_style()) {
let codes = style.make_ansi_codes(ColorSystem::TrueColor);
if !codes.is_empty() {
for part in codes.split(';') {
let _: u32 = part.parse().expect("code part should be numeric");
}
}
}
#[test]
fn prop_style_combine_associative(
a in random_style(),
b in random_style(),
c in random_style(),
) {
let left = a.combine(&b).combine(&c);
let right = a.combine(&b.combine(&c));
prop_assert_eq!(left.color, right.color);
prop_assert_eq!(left.bgcolor, right.bgcolor);
prop_assert_eq!(left.link, right.link);
}
#[test]
fn prop_style_attribute_idempotent(_n in 0..1i32) {
let bold_once = Style::new().bold();
let bold_twice = Style::new().bold().bold();
prop_assert_eq!(bold_once.attributes, bold_twice.attributes);
let italic_once = Style::new().italic();
let italic_twice = Style::new().italic().italic();
prop_assert_eq!(italic_once.attributes, italic_twice.attributes);
let underline_once = Style::new().underline();
let underline_twice = Style::new().underline().underline();
prop_assert_eq!(underline_once.attributes, underline_twice.attributes);
let strike_once = Style::new().strike();
let strike_twice = Style::new().strike().strike();
prop_assert_eq!(strike_once.attributes, strike_twice.attributes);
}
#[test]
fn prop_style_link_preservation(url in "[a-z]{5,15}") {
let linked = Style::new().link(&url);
let null = Style::null();
let combined = linked.combine(&null);
prop_assert_eq!(combined.link, Some(url.clone()));
let other_url = format!("{url}_other");
let other_linked = Style::new().link(&other_url);
let combined2 = linked.combine(&other_linked);
prop_assert_eq!(combined2.link, Some(other_url));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn prop_measurement_min_le_max(a in 0usize..10000, b in 0usize..10000) {
let m = Measurement::new(a, b);
prop_assert!(m.minimum <= m.maximum,
"minimum {} should be <= maximum {}", m.minimum, m.maximum);
}
#[test]
fn prop_measurement_exact(size in 0usize..10000) {
let m = Measurement::exact(size);
prop_assert_eq!(m.minimum, size);
prop_assert_eq!(m.maximum, size);
prop_assert_eq!(m.span(), 0);
}
#[test]
fn prop_measurement_normalize(a in 0usize..10000, b in 0usize..10000) {
let m = Measurement { minimum: a, maximum: b }; let n = m.normalize();
prop_assert!(n.minimum <= n.maximum);
}
#[test]
fn prop_measurement_with_maximum(m in random_measurement(), cap in 0usize..2000) {
let capped = m.with_maximum(cap);
prop_assert!(capped.maximum <= cap,
"maximum {} should be <= cap {}", capped.maximum, cap);
prop_assert!(capped.minimum <= cap,
"minimum {} should be <= cap {}", capped.minimum, cap);
}
#[test]
fn prop_measurement_with_minimum(m in random_measurement(), floor in 0usize..2000) {
let floored = m.with_minimum(floor);
prop_assert!(floored.minimum >= floor,
"minimum {} should be >= floor {}", floored.minimum, floor);
prop_assert!(floored.maximum >= floor,
"maximum {} should be >= floor {}", floored.maximum, floor);
}
#[test]
fn prop_measurement_clamp(
m in random_measurement(),
min_bound in prop::option::of(0usize..500),
max_bound in prop::option::of(500usize..2000),
) {
let clamped = m.clamp(min_bound, max_bound);
if let Some(min_b) = min_bound {
prop_assert!(clamped.minimum >= min_b.min(max_bound.unwrap_or(usize::MAX)),
"clamped minimum should respect lower bound");
}
if let Some(max_b) = max_bound {
prop_assert!(clamped.maximum <= max_b,
"clamped maximum should respect upper bound");
}
}
#[test]
fn prop_measurement_union_commutative(a in random_measurement(), b in random_measurement()) {
let ab = a.union(&b);
let ba = b.union(&a);
prop_assert_eq!(ab.minimum, ba.minimum);
prop_assert_eq!(ab.maximum, ba.maximum);
}
#[test]
fn prop_measurement_union_associative(
a in random_measurement(),
b in random_measurement(),
c in random_measurement(),
) {
let ab_c = a.union(&b).union(&c);
let a_bc = a.union(&b.union(&c));
prop_assert_eq!(ab_c.minimum, a_bc.minimum);
prop_assert_eq!(ab_c.maximum, a_bc.maximum);
}
#[test]
fn prop_measurement_add_commutative(a in random_measurement(), b in random_measurement()) {
let ab = a + b;
let ba = b + a;
prop_assert_eq!(ab.minimum, ba.minimum);
prop_assert_eq!(ab.maximum, ba.maximum);
}
#[test]
fn prop_measurement_span_nonnegative(m in random_measurement()) {
let span = m.span();
prop_assert!(span <= m.maximum, "span should be <= maximum");
}
#[test]
fn prop_measurement_fits(m in random_measurement()) {
prop_assert!(m.fits(m.minimum), "minimum should fit");
prop_assert!(m.fits(m.maximum), "maximum should fit");
if m.minimum > 0 {
prop_assert!(!m.fits(m.minimum - 1), "below minimum should not fit");
}
if m.maximum < usize::MAX {
prop_assert!(!m.fits(m.maximum + 1), "above maximum should not fit");
}
}
#[test]
fn prop_measurement_intersect_disjoint(
a_min in 0usize..100,
a_span in 0usize..50,
gap in 1usize..100,
b_span in 0usize..50,
) {
let a = Measurement::new(a_min, a_min + a_span);
let b_min = a_min + a_span + gap;
let b = Measurement::new(b_min, b_min + b_span);
prop_assert!(a.intersect(&b).is_none(), "disjoint ranges should not intersect");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn prop_segment_split_preserves_content(text in ascii_text(), pos in 0usize..200) {
let segment = Segment::plain(&text);
let (left, right) = segment.split_at_cell(pos);
let combined = format!("{}{}", left.text, right.text);
prop_assert_eq!(combined, text, "split should preserve content");
}
#[test]
fn prop_segment_split_at_zero(text in ascii_text()) {
let segment = Segment::plain(text.clone());
let (left, right) = segment.split_at_cell(0);
prop_assert!(left.text.is_empty(), "split at 0 should give empty left");
prop_assert_eq!(right.text, text, "split at 0 should give full right");
}
#[test]
fn prop_segment_split_beyond_length(text in ascii_text()) {
let segment = Segment::plain(text.clone());
let (left, right) = segment.split_at_cell(1000);
prop_assert_eq!(left.text, text, "split beyond length should give full left");
prop_assert!(right.text.is_empty(), "split beyond length should give empty right");
}
#[test]
fn prop_segment_cell_length_consistent(text in ascii_text()) {
let segment = Segment::plain(&text);
let len1 = segment.cell_length();
let len2 = segment.cell_length();
prop_assert_eq!(len1, len2, "cell_length should be consistent");
}
#[test]
fn prop_segment_control_zero_width(_n in 0..10i32) {
use rich_rust::segment::{ControlCode, ControlType};
let segment = Segment::control(vec![ControlCode::new(ControlType::Bell)]);
prop_assert_eq!(segment.cell_length(), 0, "control segments should have zero width");
prop_assert!(segment.is_control(), "should be marked as control");
}
#[test]
fn prop_segment_split_preserves_style(text in ascii_text(), pos in 0usize..100) {
let style = Style::new().bold().italic();
let segment = Segment::styled(&text, style.clone());
let (left, right) = segment.split_at_cell(pos);
if !left.text.is_empty() {
prop_assert_eq!(left.style, Some(style.clone()), "left should preserve style");
}
if !right.text.is_empty() {
prop_assert_eq!(right.style, Some(style), "right should preserve style");
}
}
#[test]
fn prop_segment_empty(_n in 0..1i32) {
let segment = Segment::plain("");
prop_assert!(segment.is_empty(), "empty segment should be empty");
prop_assert_eq!(segment.cell_length(), 0, "empty segment should have zero width");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn prop_text_divide_empty_offsets(text in ascii_text()) {
let t = Text::new(&text);
let parts = t.divide(&[]);
prop_assert_eq!(parts.len(), 1, "divide with no offsets should return 1 part");
prop_assert_eq!(parts[0].plain(), text, "divide with no offsets should preserve content");
}
#[test]
fn prop_text_divide_concat(text in ascii_text(), offsets in prop::collection::vec(0usize..200, 0..5)) {
let t = Text::new(&text);
let mut sorted_offsets: Vec<usize> = offsets.into_iter().collect();
sorted_offsets.sort();
sorted_offsets.dedup();
let parts = t.divide(&sorted_offsets);
let concatenated: String = parts.iter().map(|p| p.plain()).collect();
prop_assert_eq!(concatenated, text, "divide then concat should preserve content");
}
#[test]
fn prop_text_slice_bounds(text in ascii_text(), start in 0usize..150, len in 0usize..50) {
let t = Text::new(&text);
let text_len = t.len();
let end = (start + len).min(text_len);
let actual_start = start.min(text_len);
let sliced = t.slice(actual_start, end);
prop_assert!(sliced.len() <= end.saturating_sub(actual_start) + 1,
"slice length should be bounded");
}
#[test]
fn prop_text_slice_full(text in ascii_text()) {
let t = Text::new(&text);
let sliced = t.slice(0, t.len());
prop_assert_eq!(sliced.plain(), text, "full slice should equal original");
}
#[test]
fn prop_text_slice_empty(text in ascii_text()) {
let t = Text::new(&text);
let sliced = t.slice(0, 0);
prop_assert!(sliced.is_empty(), "zero-length slice should be empty");
}
#[test]
fn prop_text_append(text1 in ascii_text(), text2 in ascii_text()) {
let mut t = Text::new(&text1);
t.append(&text2);
let expected = format!("{text1}{text2}");
prop_assert_eq!(t.plain(), expected, "append should concatenate");
}
#[test]
fn prop_text_split_lines_content(lines in prop::collection::vec(ascii_text(), 1..5)) {
let text = lines.join("\n");
let t = Text::new(&text);
let split = t.split_lines();
let rejoined: String = split.iter()
.map(|l| l.plain())
.collect::<Vec<_>>()
.join("\n");
prop_assert_eq!(rejoined, text, "split_lines then join should preserve content");
}
#[test]
fn prop_text_len_char_count(text in ascii_text()) {
let t = Text::new(&text);
prop_assert_eq!(t.len(), text.chars().count(), "len should equal char count");
}
#[test]
fn prop_text_stylize_preserves_plain(text in ascii_text(), start in 0usize..50, len in 1usize..20) {
let mut t = Text::new(&text);
let end = start + len;
t.stylize(start, end, Style::new().bold());
prop_assert_eq!(t.plain(), text, "stylize should not change plain text");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn prop_integration_style_color_systems((r, g, b) in rgb_triplet(), text in ascii_text()) {
let style = Style::new().color(Color::from_rgb(r, g, b));
let _truecolor = style.render(&text, ColorSystem::TrueColor);
let _eightbit = style.render(&text, ColorSystem::EightBit);
let _standard = style.render(&text, ColorSystem::Standard);
prop_assert!(_truecolor.contains(&text) || text.is_empty());
prop_assert!(_eightbit.contains(&text) || text.is_empty());
prop_assert!(_standard.contains(&text) || text.is_empty());
}
#[test]
fn prop_integration_text_to_segment(text in ascii_text()) {
let t = Text::new(&text);
let plain = t.plain();
let segment = Segment::plain(plain);
prop_assert_eq!(segment.text, text, "text to segment should preserve content");
}
}
use rich_rust::prelude::{Column, Table};
use rich_rust::segment::split_lines;
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn prop_table_structure(num_cols in 1usize..6, num_rows in 1usize..5) {
let mut table = Table::new();
for i in 0..num_cols {
table.add_column(Column::new(format!("Col{i}")));
}
for _row_idx in 0..num_rows {
let cells: Vec<&str> = (0..num_cols).map(|_| "x").collect();
table.add_row_cells(cells);
}
let output = table.render_plain(80);
prop_assert!(!output.is_empty(), "table should produce output");
for i in 0..num_cols {
prop_assert!(output.contains(&format!("Col{i}")),
"missing header Col{i}");
}
}
#[test]
fn prop_table_empty_handling(_n in 0..1i32) {
let table = Table::new();
let segments = table.render(80);
let _ = segments;
}
#[test]
fn prop_table_single_column(num_rows in 1usize..5, cell_text in "[a-zA-Z0-9]{1,20}") {
let mut table = Table::new()
.with_column(Column::new("Header"));
for _ in 0..num_rows {
table.add_row_cells([cell_text.as_str()]);
}
let output = table.render_plain(80);
prop_assert!(output.contains("Header"), "missing Header");
prop_assert!(output.contains(&cell_text) || output.contains("…"),
"should contain cell text '{}' or ellipsis if truncated", cell_text);
}
#[test]
fn prop_table_single_row(num_cols in 1usize..5) {
let mut table = Table::new();
for i in 0..num_cols {
table.add_column(Column::new(format!("H{i}")));
}
let cells: Vec<String> = (0..num_cols).map(|i| format!("C{i}")).collect();
table.add_row_cells(cells.iter().map(|s| s.as_str()));
let output = table.render_plain(80);
prop_assert!(!output.is_empty(), "single row table should produce output");
}
#[test]
fn prop_table_width_constraint(width in 20usize..120) {
let mut table = Table::new()
.with_column(Column::new("A"))
.with_column(Column::new("B"));
table.add_row_cells(["Cell A", "Cell B"]);
let segments = table.render(width);
let lines = split_lines(segments.into_iter().map(|s| s.into_owned()));
for line in lines {
let line_width: usize = line.iter().map(|s| s.cell_length()).sum();
prop_assert!(line_width <= width,
"line width {} should not exceed constraint {}", line_width, width);
}
}
#[test]
fn prop_table_cell_content_preserved(cell_text in "[a-z]{1,10}") {
let mut table = Table::new()
.with_column(Column::new("Header"));
table.add_row_cells([cell_text.as_str()]);
let output = table.render_plain(80);
prop_assert!(output.contains(&cell_text) || output.contains("…"),
"cell text '{}' should appear or be truncated with ellipsis", cell_text);
}
#[test]
fn prop_table_row_height_consistent(cols in 2usize..5, long_text in "[a-z ]{20,50}") {
let mut table = Table::new();
for i in 0..cols {
table.add_column(Column::new(format!("Col{i}")));
}
let mut cells: Vec<String> = vec![long_text.clone()];
for _ in 1..cols {
cells.push("X".to_string());
}
table.add_row_cells(cells.iter().map(|s| s.as_str()));
let segments = table.render(60);
prop_assert!(!segments.is_empty(), "table with content should produce output");
}
#[test]
fn prop_table_border_chars_valid(_n in 0..1i32) {
let mut table = Table::new()
.with_column(Column::new("A"))
.with_column(Column::new("B"));
table.add_row_cells(["1", "2"]);
let output = table.render_plain(40);
for ch in output.chars() {
let is_box_drawing = ('\u{2500}'..='\u{257F}').contains(&ch);
prop_assert!(
ch.is_alphanumeric() || ch.is_whitespace() || ch == '…' ||
is_box_drawing || ch == '\n',
"unexpected character: {:?} (U+{:04X})", ch, ch as u32
);
}
}
}