use proptest::prelude::*;
use ratatui_core::backend::{Backend, TestBackend};
use ratatui_core::layout::{Position, Rect};
use ratatui_core::terminal::{Terminal, TerminalOptions};
use ratatui_core::text::Line;
use crate::components::Text;
use crate::geometry::{Axis, Padding, Size};
use crate::layout::{Align, Dimension, Direction, Item, Justify, LayoutStyle, solve};
use crate::overlay::{Anchor, Extent, OverlaySpec};
use crate::screen::{ScreenMode, Scrollback, close_footer, pin_footer};
use crate::style::Theme;
use crate::tests::support::row;
use crate::view::element;
fn footer_terminal(width: u16, height: u16, footer: u16, cursor_row: u16) -> Terminal<TestBackend> {
let mut backend = TestBackend::new(width, height);
backend
.set_cursor_position(Position::new(0, cursor_row.min(height.saturating_sub(1))))
.expect("place cursor");
Terminal::with_options(
backend,
TerminalOptions {
viewport: ScreenMode::split_footer(footer).viewport(),
},
)
.expect("inline terminal")
}
fn draw_footer(terminal: &mut Terminal<TestBackend>) {
let theme = Theme::default();
terminal
.draw(|frame| {
let area = frame.area();
let lines = vec![Line::from("FOOTER"); area.height as usize];
let view = element(Text::new(lines));
crate::paint(frame.buffer_mut(), area, &theme, view.as_ref(), &[]);
})
.expect("draw footer");
}
fn dimension_strategy() -> impl Strategy<Value = Dimension> {
prop_oneof![
Just(Dimension::Auto),
(0u16..60).prop_map(Dimension::Fixed),
(0u16..=100).prop_map(Dimension::Percent),
(1u16..6).prop_map(Dimension::Flex),
]
}
fn item_strategy() -> impl Strategy<Value = Item> {
(dimension_strategy(), 0u16..60, 0u16..30).prop_map(|(d, w, h)| Item::new(d, Size::new(w, h)))
}
fn align_strategy() -> impl Strategy<Value = Align> {
prop_oneof![
Just(Align::Start),
Just(Align::Center),
Just(Align::End),
Just(Align::Stretch),
]
}
fn justify_strategy() -> impl Strategy<Value = Justify> {
prop_oneof![
Just(Justify::Start),
Just(Justify::Center),
Just(Justify::End),
Just(Justify::SpaceBetween),
]
}
fn direction_strategy() -> impl Strategy<Value = Direction> {
prop_oneof![Just(Direction::Row), Just(Direction::Column)]
}
proptest! {
#[test]
fn solved_children_stay_within_area(
w in 0u16..300,
h in 0u16..120,
items in proptest::collection::vec(item_strategy(), 1..8),
gap in 0u16..30,
pad in 0u16..6,
dir in direction_strategy(),
align in align_strategy(),
justify in justify_strategy(),
) {
let area = Rect::new(0, 0, w, h);
let style = LayoutStyle {
direction: dir,
padding: Padding::all(pad),
gap,
align_items: align,
justify,
};
let rects = solve(area, &style, &items);
prop_assert_eq!(rects.len(), items.len());
let axis = dir.axis();
let mut last_main = 0u16;
for r in &rects {
prop_assert!(
r.x >= area.x && r.right() <= area.right(),
"x out of bounds: {:?} in {:?}",
r,
area
);
prop_assert!(
r.y >= area.y && r.bottom() <= area.bottom(),
"y out of bounds: {:?} in {:?}",
r,
area
);
let main = match axis {
Axis::Horizontal => r.x,
Axis::Vertical => r.y,
};
prop_assert!(main >= last_main, "children placed out of order: {:?}", rects);
last_main = main;
}
}
#[test]
fn flex_children_fill_main_axis_exactly(w in 0u16..500, n in 1usize..8) {
let items: Vec<Item> = (0..n)
.map(|_| Item::new(Dimension::Flex(1), Size::ZERO))
.collect();
let rects = solve(Rect::new(0, 0, w, 1), &LayoutStyle::row(), &items);
let total: u16 = rects.iter().map(|r| r.width).fold(0, u16::saturating_add);
prop_assert_eq!(total, w, "flex children must fill width exactly");
}
#[test]
fn overlay_stays_within_screen(
w in 0u16..300,
h in 0u16..120,
pct_w in 0u16..=100,
pct_h in 0u16..=100,
margin in 0u16..12,
anchor_i in 0usize..9,
max_w in 1u16..300,
max_h in 1u16..120,
) {
let anchors = [
Anchor::Center,
Anchor::Top,
Anchor::Bottom,
Anchor::Left,
Anchor::Right,
Anchor::TopLeft,
Anchor::TopRight,
Anchor::BottomLeft,
Anchor::BottomRight,
];
let screen = Rect::new(0, 0, w, h);
let spec = OverlaySpec {
anchor: anchors[anchor_i],
width: Extent::Percent(pct_w),
height: Extent::Percent(pct_h),
min_width: 0,
min_height: 0,
max_width: max_w,
max_height: max_h,
margin,
};
let r = spec.resolve(screen);
prop_assert!(
r.x >= screen.x && r.right() <= screen.right(),
"overlay x out of bounds: {:?} in {:?}",
r,
screen
);
prop_assert!(
r.y >= screen.y && r.bottom() <= screen.bottom(),
"overlay y out of bounds: {:?} in {:?}",
r,
screen
);
prop_assert!(r.width <= max_w, "width exceeds max");
prop_assert!(r.height <= max_h, "height exceeds max");
}
}
proptest! {
#[test]
fn a_pinned_footer_always_owns_the_last_rows(
width in 1u16..80,
height in 1u16..40,
footer in 1u16..24,
cursor_row in 0u16..40,
) {
let mut terminal = footer_terminal(width, height, footer, cursor_row);
pin_footer(&mut terminal).expect("pin");
let area = terminal.get_frame().area();
prop_assert_eq!(area.height, footer.min(height), "footer clamps to the screen");
prop_assert_eq!(area.bottom(), height, "footer sits on the last row");
prop_assert_eq!(area.x, 0);
prop_assert_eq!(area.width, width);
pin_footer(&mut terminal).expect("re-pin");
prop_assert_eq!(terminal.get_frame().area(), area, "pinning is idempotent");
}
#[test]
fn publishing_never_costs_the_footer_its_rows(
width in 4u16..40,
height in 2u16..24,
footer in 1u16..8,
blocks in proptest::collection::vec(1u16..30, 0..6),
) {
let theme = Theme::default();
let mut terminal = footer_terminal(width, height, footer, 0);
pin_footer(&mut terminal).expect("pin");
draw_footer(&mut terminal);
let scrollback = Scrollback::new();
for (i, &rows) in blocks.iter().enumerate() {
scrollback.write(move |_width| {
element(Text::new(
(0..rows).map(|r| Line::from(format!("b{i}.{r}"))).collect::<Vec<_>>(),
))
});
}
scrollback.flush(&mut terminal, &theme).expect("flush");
prop_assert!(scrollback.is_empty(), "every block was consumed");
pin_footer(&mut terminal).expect("re-pin");
draw_footer(&mut terminal);
let area = terminal.get_frame().area();
prop_assert_eq!(area.bottom(), height, "the footer is still pinned");
let buffer = terminal.backend().buffer();
for y in area.top()..area.bottom() {
let painted = row(buffer, y);
prop_assert!(
painted.starts_with(&"FOOTER"[..(width as usize).min(6)]),
"footer row {} was overwritten by published output: {:?}",
y,
painted
);
}
}
#[test]
fn closing_returns_the_footer_rows_and_nothing_else(
width in 6u16..40,
height in 2u16..24,
footer in 1u16..8,
) {
let theme = Theme::default();
let mut terminal = footer_terminal(width, height, footer, 0);
pin_footer(&mut terminal).expect("pin");
let scrollback = Scrollback::new();
scrollback.write(|_width| element(Text::raw("kept")));
scrollback.flush(&mut terminal, &theme).expect("flush");
pin_footer(&mut terminal).expect("re-pin");
draw_footer(&mut terminal);
let area = terminal.get_frame().area();
close_footer(&mut terminal).expect("close");
prop_assert_eq!(
terminal.backend().cursor_position(),
Position::new(area.x, area.y),
"the prompt resumes at the top of the released rows"
);
let buffer = terminal.backend().buffer();
for y in area.top()..area.bottom() {
prop_assert_eq!(row(buffer, y), "", "row {} was not released", y);
}
if area.top() > 0 {
let above: Vec<String> = (0..area.top()).map(|y| row(buffer, y)).collect();
prop_assert!(
above.iter().any(|line| line == "kept"),
"published scrollback was cleared: {:?}",
above
);
}
}
}