slt/test_utils.rs
1//! Headless testing utilities.
2//!
3//! [`TestBackend`] renders a UI closure to an in-memory buffer without a real
4//! terminal. [`EventBuilder`] constructs event sequences for simulating user
5//! input. Together they enable snapshot and assertion-based UI testing.
6
7use crate::buffer::Buffer;
8use crate::context::Context;
9use crate::event::{
10 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, ModifierKey, MouseButton, MouseEvent,
11 MouseKind,
12};
13use crate::rect::Rect;
14use crate::style::Style;
15use crate::{run_frame_kernel, FrameState, RunConfig};
16
17/// Builder for constructing a sequence of input [`Event`]s.
18///
19/// Chain calls to [`key`](EventBuilder::key), [`click`](EventBuilder::click),
20/// [`scroll_up`](EventBuilder::scroll_up), etc., then call
21/// [`build`](EventBuilder::build) to get the final `Vec<Event>`.
22///
23/// # Example
24///
25/// ```
26/// use slt::EventBuilder;
27/// use slt::KeyCode;
28///
29/// let events = EventBuilder::new()
30/// .key('a')
31/// .key_code(KeyCode::Enter)
32/// .build();
33/// assert_eq!(events.len(), 2);
34/// ```
35pub struct EventBuilder {
36 events: Vec<Event>,
37}
38
39impl EventBuilder {
40 /// Create an empty event builder.
41 pub fn new() -> Self {
42 Self { events: Vec::new() }
43 }
44
45 /// Append a character key-press event.
46 pub fn key(mut self, c: char) -> Self {
47 self.events.push(Event::Key(KeyEvent {
48 code: KeyCode::Char(c),
49 modifiers: KeyModifiers::NONE,
50 kind: KeyEventKind::Press,
51 }));
52 self
53 }
54
55 /// Append a special key-press event (arrows, Enter, Esc, etc.).
56 pub fn key_code(mut self, code: KeyCode) -> Self {
57 self.events.push(Event::Key(KeyEvent {
58 code,
59 modifiers: KeyModifiers::NONE,
60 kind: KeyEventKind::Press,
61 }));
62 self
63 }
64
65 /// Append a key-press event with modifier keys (Ctrl, Shift, Alt).
66 pub fn key_with(mut self, code: KeyCode, modifiers: KeyModifiers) -> Self {
67 self.events.push(Event::Key(KeyEvent {
68 code,
69 modifiers,
70 kind: KeyEventKind::Press,
71 }));
72 self
73 }
74
75 /// Append a modifier-only key-press event (a bare Ctrl/Shift/Alt/Super
76 /// press with no accompanying character).
77 ///
78 /// Mirrors what the Kitty keyboard protocol delivers when
79 /// [`RunConfig::report_all_keys(true)`](crate::RunConfig::report_all_keys)
80 /// is enabled, so widget tests can simulate modifier-only presses without
81 /// poking crossterm. The event carries [`KeyCode::Modifier`] with
82 /// [`KeyModifiers::NONE`].
83 ///
84 /// Since 0.21.0.
85 ///
86 /// # Example
87 ///
88 /// ```
89 /// use slt::{EventBuilder, KeyCode, ModifierKey};
90 ///
91 /// let events = EventBuilder::new()
92 /// .key_modifier(ModifierKey::LeftCtrl)
93 /// .build();
94 /// assert_eq!(events.len(), 1);
95 /// ```
96 pub fn key_modifier(mut self, m: ModifierKey) -> Self {
97 self.events.push(Event::Key(KeyEvent {
98 code: KeyCode::Modifier(m),
99 modifiers: KeyModifiers::NONE,
100 kind: KeyEventKind::Press,
101 }));
102 self
103 }
104
105 /// Append a left mouse click at terminal position `(x, y)`.
106 pub fn click(mut self, x: u32, y: u32) -> Self {
107 self.events.push(Event::Mouse(MouseEvent {
108 kind: MouseKind::Down(MouseButton::Left),
109 x,
110 y,
111 modifiers: KeyModifiers::NONE,
112 pixel_x: None,
113 pixel_y: None,
114 }));
115 self
116 }
117
118 /// Append a left-button press at `(x, y)` carrying the given modifiers.
119 ///
120 /// Use this to simulate `Shift`+click (e.g. range extension in the
121 /// calendar widget). The plain [`click`](EventBuilder::click) helper
122 /// always sends `KeyModifiers::NONE`.
123 pub fn click_with(mut self, x: u32, y: u32, modifiers: KeyModifiers) -> Self {
124 self.events.push(Event::Mouse(MouseEvent {
125 kind: MouseKind::Down(MouseButton::Left),
126 x,
127 y,
128 modifiers,
129 pixel_x: None,
130 pixel_y: None,
131 }));
132 self
133 }
134
135 /// Append a left mouse button release at terminal position `(x, y)`.
136 pub fn mouse_up(mut self, x: u32, y: u32) -> Self {
137 self.events.push(Event::mouse_up(x, y));
138 self
139 }
140
141 /// Append a mouse drag (movement with the left button held) at `(x, y)`.
142 pub fn drag(mut self, x: u32, y: u32) -> Self {
143 self.events.push(Event::mouse_drag(x, y));
144 self
145 }
146
147 /// Append a key-release event for character `c`.
148 ///
149 /// Only meaningful on terminals that emit release events
150 /// (e.g. with the Kitty keyboard protocol enabled).
151 pub fn key_release(mut self, c: char) -> Self {
152 self.events.push(Event::key_release(c));
153 self
154 }
155
156 /// Append a terminal focus-gained event.
157 pub fn focus_gained(mut self) -> Self {
158 self.events.push(Event::FocusGained);
159 self
160 }
161
162 /// Append a terminal focus-lost event.
163 pub fn focus_lost(mut self) -> Self {
164 self.events.push(Event::FocusLost);
165 self
166 }
167
168 /// Append a scroll-up event at `(x, y)`.
169 pub fn scroll_up(mut self, x: u32, y: u32) -> Self {
170 self.events.push(Event::Mouse(MouseEvent {
171 kind: MouseKind::ScrollUp,
172 x,
173 y,
174 modifiers: KeyModifiers::NONE,
175 pixel_x: None,
176 pixel_y: None,
177 }));
178 self
179 }
180
181 /// Append a scroll-down event at `(x, y)`.
182 pub fn scroll_down(mut self, x: u32, y: u32) -> Self {
183 self.events.push(Event::Mouse(MouseEvent {
184 kind: MouseKind::ScrollDown,
185 x,
186 y,
187 modifiers: KeyModifiers::NONE,
188 pixel_x: None,
189 pixel_y: None,
190 }));
191 self
192 }
193
194 /// Append a bracketed-paste event.
195 pub fn paste(mut self, text: impl Into<String>) -> Self {
196 self.events.push(Event::Paste(text.into()));
197 self
198 }
199
200 /// Append a terminal resize event.
201 pub fn resize(mut self, width: u32, height: u32) -> Self {
202 self.events.push(Event::Resize(width, height));
203 self
204 }
205
206 /// Consume the builder and return the event sequence.
207 pub fn build(self) -> Vec<Event> {
208 self.events
209 }
210}
211
212impl Default for EventBuilder {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218/// Headless rendering backend for tests.
219///
220/// Renders a UI closure to an in-memory [`Buffer`] without a real terminal.
221/// Use [`render`](TestBackend::render) to run one frame, then inspect the
222/// output with [`line`](TestBackend::line), [`assert_contains`](TestBackend::assert_contains),
223/// or [`to_string_trimmed`](TestBackend::to_string_trimmed).
224/// Session state persists across renders, so multi-frame tests can exercise
225/// hooks, focus, and previous-frame hit testing.
226///
227/// # Example
228///
229/// ```
230/// use slt::TestBackend;
231///
232/// let mut backend = TestBackend::new(40, 10);
233/// backend.render(|ui| {
234/// ui.text("hello");
235/// });
236/// backend.assert_contains("hello");
237/// ```
238pub struct TestBackend {
239 buffer: Buffer,
240 width: u32,
241 height: u32,
242 frame_state: FrameState,
243 /// Frame history. `None` = recording disabled (zero overhead).
244 /// `Some(_)` = recording enabled — every [`render`](TestBackend::render)
245 /// call appends a [`FrameRecord`].
246 frames: Option<Vec<FrameRecord>>,
247}
248
249/// Snapshot of a single rendered frame, captured by
250/// [`TestBackend::record_frames`].
251///
252/// Stores the styled snapshot string (via [`Buffer::snapshot_format`]) plus a
253/// per-row trimmed text view for ergonomic substring assertions. Both are
254/// produced from the same buffer and are guaranteed to refer to the same
255/// frame.
256///
257/// Cheap to clone; useful for replaying a failing test by inspecting
258/// intermediate frames.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct FrameRecord {
261 /// Styled snapshot of the buffer at this frame, in the stable
262 /// [`Buffer::snapshot_format`] vocabulary.
263 pub snapshot: String,
264 /// Plain-text view of each buffer row, trailing spaces trimmed.
265 /// Mirrors [`TestBackend::line`] for every row.
266 pub lines: Vec<String>,
267}
268
269impl FrameRecord {
270 /// Return the frame as a multi-line string (rows joined with `\n`,
271 /// trailing empty rows preserved). Mirrors [`TestBackend::to_string_trimmed`]
272 /// on the originating buffer.
273 pub fn to_string_trimmed(&self) -> String {
274 let mut lines = self.lines.clone();
275 while lines.last().is_some_and(|l| l.is_empty()) {
276 lines.pop();
277 }
278 lines.join("\n")
279 }
280
281 /// Return the trimmed text of row `y` from this frame, or empty if `y`
282 /// is past the buffer height.
283 pub fn line(&self, y: u32) -> &str {
284 self.lines
285 .get(y as usize)
286 .map(|s| s.as_str())
287 .unwrap_or_default()
288 }
289
290 /// Assert any row in this frame contains `expected`. Panics with a
291 /// row-by-row dump on failure.
292 pub fn assert_contains(&self, expected: &str) {
293 for line in &self.lines {
294 if line.contains(expected) {
295 return;
296 }
297 }
298 let mut detail = String::new();
299 for (y, line) in self.lines.iter().enumerate() {
300 detail.push_str(&format!(" {y}: {line}\n"));
301 }
302 panic!("FrameRecord does not contain {expected:?}.\nFrame:\n{detail}");
303 }
304}
305
306impl TestBackend {
307 /// Create a test backend with the given terminal dimensions.
308 pub fn new(width: u32, height: u32) -> Self {
309 let area = Rect::new(0, 0, width, height);
310 Self {
311 buffer: Buffer::empty(area),
312 width,
313 height,
314 frame_state: FrameState::default(),
315 frames: None,
316 }
317 }
318
319 /// Enable frame recording.
320 ///
321 /// After this call, every subsequent [`render`](TestBackend::render),
322 /// [`render_with_events`](TestBackend::render_with_events), and
323 /// [`run_with_events`](TestBackend::run_with_events) call appends a
324 /// [`FrameRecord`] to the internal history. Disabled by default so tests
325 /// that don't need history pay zero memory overhead.
326 ///
327 /// Returns `self` for chaining.
328 ///
329 /// # Example
330 ///
331 /// ```
332 /// use slt::TestBackend;
333 ///
334 /// let mut tb = TestBackend::new(20, 3).record_frames();
335 /// for n in 0..3 {
336 /// tb.render(|ui| {
337 /// ui.text(format!("frame {n}"));
338 /// });
339 /// }
340 /// assert_eq!(tb.frames().len(), 3);
341 /// tb.frames()[0].assert_contains("frame 0");
342 /// tb.frames()[2].assert_contains("frame 2");
343 /// ```
344 pub fn record_frames(mut self) -> Self {
345 if self.frames.is_none() {
346 self.frames = Some(Vec::new());
347 }
348 self
349 }
350
351 /// Return all captured frame snapshots in chronological order.
352 ///
353 /// Returns an empty slice if [`record_frames`](TestBackend::record_frames)
354 /// was never called on this backend.
355 pub fn frames(&self) -> &[FrameRecord] {
356 self.frames.as_deref().unwrap_or(&[])
357 }
358
359 /// Capture the current buffer state into the recording, if enabled.
360 ///
361 /// No-op when recording is off — keeps the hot path allocation-free
362 /// for the common case.
363 fn capture_frame(&mut self) {
364 if let Some(frames) = self.frames.as_mut() {
365 let snapshot = self.buffer.snapshot_format();
366 let mut lines = Vec::with_capacity(self.height as usize);
367 for y in 0..self.height {
368 let mut s = String::new();
369 for x in 0..self.width {
370 s.push_str(&self.buffer.get(x, y).symbol);
371 }
372 lines.push(s.trim_end().to_string());
373 }
374 frames.push(FrameRecord { snapshot, lines });
375 }
376 }
377
378 fn render_frame(
379 &mut self,
380 events: Vec<Event>,
381 setup_state: impl FnOnce(&mut FrameState),
382 f: impl FnOnce(&mut Context),
383 ) {
384 setup_state(&mut self.frame_state);
385
386 self.buffer.reset();
387 let mut once = Some(f);
388 let mut render = |ui: &mut Context| {
389 if let Some(f) = once.take() {
390 f(ui);
391 } else {
392 panic!("render closure called twice");
393 }
394 };
395 let _ = run_frame_kernel(
396 &mut self.buffer,
397 &mut self.frame_state,
398 &RunConfig::default(),
399 (self.width, self.height),
400 events,
401 false,
402 &mut render,
403 );
404 self.capture_frame();
405 }
406
407 /// Run a UI closure for one frame and render to the internal buffer.
408 pub fn render(&mut self, f: impl FnOnce(&mut Context)) {
409 self.render_frame(Vec::new(), |_| {}, f);
410 }
411
412 /// Render with injected events and focus state for interaction testing.
413 pub fn render_with_events(
414 &mut self,
415 events: Vec<Event>,
416 focus_index: usize,
417 prev_focus_count: usize,
418 f: impl FnOnce(&mut Context),
419 ) {
420 self.render_frame(
421 events,
422 |state| {
423 state.focus.focus_index = focus_index;
424 state.focus.prev_focus_count = prev_focus_count;
425 },
426 f,
427 );
428 }
429
430 /// Convenience wrapper: render with events using default focus state.
431 pub fn run_with_events(&mut self, events: Vec<Event>, f: impl FnOnce(&mut crate::Context)) {
432 self.render_with_events(events, 0, 0, f);
433 }
434
435 /// Number of live frame-clock scheduler timer slots persisted after the
436 /// most recent render (issue #248). Test-only — used to assert that
437 /// abandoned timers are garbage-collected and `SchedulerState` does not
438 /// grow without bound.
439 #[cfg(test)]
440 pub(crate) fn scheduler_slot_count(&self) -> usize {
441 self.frame_state.scheduler.slot_count()
442 }
443
444 /// Inject the ambient Tokio runtime handle so `Context::spawn` works inside
445 /// rendered frames (issue #234). Mirrors what `run_async_loop` does once
446 /// before its loop; test-only — real async runs go through `run_async`.
447 #[cfg(all(test, feature = "async"))]
448 pub(crate) fn set_async_runtime(&mut self, handle: tokio::runtime::Handle) {
449 self.frame_state.async_tasks.set_runtime(handle);
450 }
451
452 /// Get the rendered text content of row y (trimmed trailing spaces)
453 pub fn line(&self, y: u32) -> String {
454 let mut s = String::new();
455 for x in 0..self.width {
456 s.push_str(&self.buffer.get(x, y).symbol);
457 }
458 s.trim_end().to_string()
459 }
460
461 /// Assert that row y contains `expected` as a substring
462 pub fn assert_line(&self, y: u32, expected: &str) {
463 let line = self.line(y);
464 assert_eq!(
465 line, expected,
466 "Line {y}: expected {expected:?}, got {line:?}"
467 );
468 }
469
470 /// Assert that row y contains `expected` as a substring
471 pub fn assert_line_contains(&self, y: u32, expected: &str) {
472 let line = self.line(y);
473 assert!(
474 line.contains(expected),
475 "Line {y}: expected to contain {expected:?}, got {line:?}"
476 );
477 }
478
479 /// Assert that any line in the buffer contains `expected`
480 pub fn assert_contains(&self, expected: &str) {
481 for y in 0..self.height {
482 if self.line(y).contains(expected) {
483 return;
484 }
485 }
486 let mut all_lines = String::new();
487 for y in 0..self.height {
488 all_lines.push_str(&format!("{}: {}\n", y, self.line(y)));
489 }
490 panic!("Buffer does not contain {expected:?}.\nBuffer:\n{all_lines}");
491 }
492
493 /// Access the underlying render buffer.
494 pub fn buffer(&self) -> &Buffer {
495 &self.buffer
496 }
497
498 /// Terminal width used for this backend.
499 pub fn width(&self) -> u32 {
500 self.width
501 }
502
503 /// Terminal height used for this backend.
504 pub fn height(&self) -> u32 {
505 self.height
506 }
507
508 /// Return the full rendered buffer as a multi-line string.
509 ///
510 /// Each row is trimmed of trailing spaces and joined with newlines.
511 /// Useful for snapshot testing with `insta::assert_snapshot!`.
512 pub fn to_string_trimmed(&self) -> String {
513 let mut lines = Vec::with_capacity(self.height as usize);
514 for y in 0..self.height {
515 lines.push(self.line(y));
516 }
517 while lines.last().is_some_and(|l| l.is_empty()) {
518 lines.pop();
519 }
520 lines.join("\n")
521 }
522
523 // ---- Negative assertions (#232) ---------------------------------------
524
525 /// Assert that no row in the buffer contains `expected` as a substring.
526 ///
527 /// Panics with the offending row indices and contents on failure.
528 pub fn assert_not_contains(&self, expected: &str) {
529 let mut offending: Vec<(u32, String)> = Vec::new();
530 for y in 0..self.height {
531 let line = self.line(y);
532 if line.contains(expected) {
533 offending.push((y, line));
534 }
535 }
536 if !offending.is_empty() {
537 let detail = offending
538 .iter()
539 .map(|(y, l)| format!(" row {y}: {l:?}"))
540 .collect::<Vec<_>>()
541 .join("\n");
542 panic!("Buffer unexpectedly contains {expected:?}:\n{detail}");
543 }
544 }
545
546 /// Assert that row `y` does NOT contain `expected` as a substring.
547 pub fn assert_line_not_contains(&self, y: u32, expected: &str) {
548 let line = self.line(y);
549 assert!(
550 !line.contains(expected),
551 "Line {y}: expected NOT to contain {expected:?}, but got {line:?}"
552 );
553 }
554
555 /// Assert that row `y` is entirely blank (contains no non-space content).
556 ///
557 /// Useful for verifying that cleared, padded, or overflow-suppressed rows
558 /// render as empty.
559 pub fn assert_empty_line(&self, y: u32) {
560 let line = self.line(y);
561 assert!(line.is_empty(), "Line {y}: expected empty, got {line:?}");
562 }
563
564 /// Assert that the cell at `(x, y)` carries exactly the `expected` style.
565 ///
566 /// Useful for focused color/modifier regression checks without committing
567 /// to a full-buffer snapshot. Panics with `(x, y)`, the actual style, and
568 /// the expected style on mismatch.
569 pub fn assert_style_at(&self, x: u32, y: u32, expected: Style) {
570 let actual = self.buffer.get(x, y).style;
571 assert_eq!(
572 actual, expected,
573 "Style mismatch at ({x}, {y}): expected {expected:?}, got {actual:?}"
574 );
575 }
576
577 // ---- Region queries + snapshot diffing (#283) -------------------------
578
579 /// Find the first buffer position where `needle` begins in the rendered
580 /// text grid, scanning rows top-to-bottom and columns left-to-right.
581 ///
582 /// Each cell contributes its glyph at the cell's own column; empty cells
583 /// (blanks and wide-char trailing cells) count as a single space so the
584 /// returned `x` is the actual buffer column where the match starts. The
585 /// search is per-row — a needle that wraps across a row boundary is not
586 /// matched. Returns `None` if `needle` is empty or absent.
587 ///
588 /// # Example
589 ///
590 /// ```
591 /// use slt::TestBackend;
592 ///
593 /// let mut tb = TestBackend::new(20, 2);
594 /// tb.render(|ui| {
595 /// ui.text(" hello");
596 /// });
597 /// assert_eq!(tb.find_text("hello"), Some((2, 0)));
598 /// assert_eq!(tb.find_text("nope"), None);
599 /// ```
600 pub fn find_text(&self, needle: &str) -> Option<(u32, u32)> {
601 if needle.is_empty() {
602 return None;
603 }
604 for y in 0..self.height {
605 // Build the row text alongside a per-character map back to the
606 // originating buffer column, so a byte match in `row` resolves to
607 // the correct `x`. Empty cells render as a single space.
608 let mut row = String::new();
609 // byte offset in `row` -> buffer column x
610 let mut col_at_byte: Vec<u32> = Vec::with_capacity(self.width as usize);
611 for x in 0..self.width {
612 let cell = self.buffer.get(x, y);
613 let sym: &str = if cell.symbol.is_empty() {
614 " "
615 } else {
616 cell.symbol.as_str()
617 };
618 for _ in 0..sym.len() {
619 col_at_byte.push(x);
620 }
621 row.push_str(sym);
622 }
623 if let Some(byte_idx) = row.find(needle) {
624 let x = col_at_byte.get(byte_idx).copied().unwrap_or(0);
625 return Some((x, y));
626 }
627 }
628 None
629 }
630
631 /// Assert that the rectangular region anchored at `(x, y)` with width `w`
632 /// and height `h` renders exactly `expected` (rows joined with `\n`).
633 ///
634 /// Each region row is the slice of buffer columns `x..x+w` on buffer row
635 /// `y..y+h`, with empty cells rendered as a space and **trailing** spaces
636 /// of each region row preserved (so width is significant). Columns or rows
637 /// that fall outside the buffer are treated as blanks. Panics with an
638 /// aligned expected-vs-actual diff on mismatch.
639 ///
640 /// # Example
641 ///
642 /// ```
643 /// use slt::TestBackend;
644 ///
645 /// let mut tb = TestBackend::new(10, 3);
646 /// tb.render(|ui| {
647 /// let _ = ui.col(|ui| {
648 /// ui.text("ab");
649 /// ui.text("cd");
650 /// });
651 /// });
652 /// tb.assert_region(0, 0, 2, 2, "ab\ncd");
653 /// ```
654 pub fn assert_region(&self, x: u32, y: u32, w: u32, h: u32, expected: &str) {
655 let actual = self.region(x, y, w, h);
656 if actual != expected {
657 panic!(
658 "Region ({x}, {y}, {w}x{h}) mismatch.\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n----------------"
659 );
660 }
661 }
662
663 /// Render the rectangular region anchored at `(x, y)` with width `w` and
664 /// height `h` as a multi-line string (rows joined with `\n`).
665 ///
666 /// Empty cells render as a single space and trailing spaces are preserved,
667 /// so the result is exactly `w` columns wide per row. Columns or rows
668 /// outside the buffer are blank-filled. Useful for scoping a snapshot to a
669 /// sub-rectangle without asserting on the full buffer.
670 pub fn region(&self, x: u32, y: u32, w: u32, h: u32) -> String {
671 let mut rows = Vec::with_capacity(h as usize);
672 for row in y..y.saturating_add(h) {
673 let mut s = String::new();
674 for col in x..x.saturating_add(w) {
675 if row < self.height && col < self.width {
676 let cell = self.buffer.get(col, row);
677 if cell.symbol.is_empty() {
678 s.push(' ');
679 } else {
680 s.push_str(cell.symbol.as_str());
681 }
682 } else {
683 s.push(' ');
684 }
685 }
686 rows.push(s);
687 }
688 rows.join("\n")
689 }
690
691 /// Assert that `needle` is rendered somewhere in the buffer AND every cell
692 /// of the matched run satisfies `predicate` (applied to each cell's
693 /// [`Style`]).
694 ///
695 /// Combines a content check with a per-cell style check, which is more
696 /// ergonomic than pairing [`find_text`](TestBackend::find_text) with
697 /// repeated [`assert_style_at`](TestBackend::assert_style_at) calls. The
698 /// run is located with `find_text` (per-row, left-to-right), then each of
699 /// the `needle`'s `char`-count cells starting at the match is tested.
700 /// Panics if the needle is absent or any covered cell fails the predicate.
701 ///
702 /// # Example
703 ///
704 /// ```
705 /// use slt::{Color, TestBackend};
706 ///
707 /// let mut tb = TestBackend::new(20, 1);
708 /// tb.render(|ui| {
709 /// ui.text("hi").fg(Color::Red).bold();
710 /// });
711 /// tb.assert_styled_contains("hi", |s| {
712 /// s.fg == Some(Color::Red) && s.modifiers.contains(slt::Modifiers::BOLD)
713 /// });
714 /// ```
715 pub fn assert_styled_contains(&self, needle: &str, predicate: impl Fn(&Style) -> bool) {
716 let Some((x, y)) = self.find_text(needle) else {
717 let mut all_lines = String::new();
718 for row in 0..self.height {
719 all_lines.push_str(&format!("{}: {}\n", row, self.line(row)));
720 }
721 panic!("Buffer does not contain {needle:?}.\nBuffer:\n{all_lines}");
722 };
723 // The match spans one cell per `char` in the needle. Wide glyphs occupy
724 // their own cell; the trailing blank cell is not part of the run.
725 let span = needle.chars().count() as u32;
726 for offset in 0..span {
727 let cx = x + offset;
728 let style = self.buffer.get(cx, y).style;
729 assert!(
730 predicate(&style),
731 "Style predicate failed for {needle:?} at cell ({cx}, {y}): style is {style:?}"
732 );
733 }
734 }
735
736 /// Produce a stable, plain-text snapshot of the whole buffer.
737 ///
738 /// Every buffer row is rendered exactly `width` columns wide (empty cells
739 /// as spaces, no trailing trim) and joined with `\n`. Unlike
740 /// [`to_string_trimmed`](TestBackend::to_string_trimmed), no trailing blank
741 /// rows are dropped and per-row width is fixed, giving a deterministic
742 /// snapshot suitable for [`assert_snapshot_eq`](TestBackend::assert_snapshot_eq)
743 /// or external snapshot tooling.
744 ///
745 /// # Example
746 ///
747 /// ```
748 /// use slt::TestBackend;
749 ///
750 /// let mut tb = TestBackend::new(3, 2);
751 /// tb.render(|ui| {
752 /// ui.text("ab");
753 /// });
754 /// assert_eq!(tb.snapshot(), "ab \n ");
755 /// ```
756 pub fn snapshot(&self) -> String {
757 self.region(0, 0, self.width, self.height)
758 }
759
760 /// Assert the buffer [`snapshot`](TestBackend::snapshot) equals `expected`,
761 /// panicking with a unified-diff-style report on mismatch.
762 ///
763 /// Trailing whitespace on each line of `expected` is ignored (the actual
764 /// snapshot is right-padded to the buffer width), so callers can write
765 /// trimmed expected strings. The panic message lists each differing row
766 /// with `-` (expected) / `+` (actual) markers.
767 ///
768 /// # Example
769 ///
770 /// ```
771 /// use slt::TestBackend;
772 ///
773 /// let mut tb = TestBackend::new(5, 2);
774 /// tb.render(|ui| {
775 /// ui.text("hi");
776 /// });
777 /// tb.assert_snapshot_eq("hi\n");
778 /// ```
779 pub fn assert_snapshot_eq(&self, expected: &str) {
780 let actual = self.snapshot();
781 // Compare row-by-row, ignoring trailing whitespace differences so the
782 // expected literal can be written without padding to the full width.
783 let actual_rows: Vec<&str> = actual.lines().collect();
784 let expected_rows: Vec<&str> = expected.lines().collect();
785 let row_count = actual_rows.len().max(expected_rows.len());
786 let mut mismatched = false;
787 for i in 0..row_count {
788 let a = actual_rows.get(i).copied().unwrap_or("");
789 let e = expected_rows.get(i).copied().unwrap_or("");
790 if a.trim_end() != e.trim_end() {
791 mismatched = true;
792 break;
793 }
794 }
795 if mismatched {
796 let mut diff = String::new();
797 for i in 0..row_count {
798 let a = actual_rows.get(i).copied().unwrap_or("");
799 let e = expected_rows.get(i).copied().unwrap_or("");
800 if a.trim_end() == e.trim_end() {
801 diff.push_str(&format!(" {}\n", a.trim_end()));
802 } else {
803 diff.push_str(&format!("- {}\n", e.trim_end()));
804 diff.push_str(&format!("+ {}\n", a.trim_end()));
805 }
806 }
807 panic!("Snapshot mismatch (- expected, + actual):\n{diff}");
808 }
809 }
810
811 // ---- Multi-step sequences + type_string (#230) ------------------------
812
813 /// Begin building a multi-step interaction sequence.
814 ///
815 /// Each [`tick`](TestSequence::tick) (or [`key`](TestSequence::key))
816 /// appends an event batch + render closure pair.
817 /// [`run`](TestSequence::run) executes them in order, advancing
818 /// `FrameState` naturally between steps so callers don't need to thread
819 /// `focus_index` / `prev_focus_count` manually.
820 ///
821 /// # Example
822 ///
823 /// ```
824 /// use slt::{KeyCode, TestBackend};
825 ///
826 /// let mut tb = TestBackend::new(20, 3);
827 /// tb.sequence()
828 /// .tick(|ui| { ui.text("ready"); })
829 /// .key(KeyCode::Esc, |ui| { ui.text("after esc"); })
830 /// .run();
831 /// tb.assert_contains("after esc");
832 /// ```
833 pub fn sequence(&mut self) -> TestSequence<'_> {
834 TestSequence {
835 backend: self,
836 steps: Vec::new(),
837 }
838 }
839
840 /// Simulate typing `s` one character at a time, rendering with `render`
841 /// between each character.
842 ///
843 /// Each character produces a [`KeyCode::Char`] event with no modifiers.
844 /// Focus state is preserved across characters.
845 ///
846 /// # Example
847 ///
848 /// ```
849 /// use slt::TestBackend;
850 ///
851 /// let mut tb = TestBackend::new(20, 3);
852 /// let mut typed = String::new();
853 /// tb.type_string("hi", |ui| {
854 /// ui.text(&typed);
855 /// });
856 /// // 2 characters → 2 frames rendered.
857 /// drop(typed);
858 /// ```
859 pub fn type_string(&mut self, s: &str, mut render: impl FnMut(&mut Context)) {
860 for ch in s.chars() {
861 let events = vec![Event::Key(KeyEvent {
862 code: KeyCode::Char(ch),
863 modifiers: KeyModifiers::NONE,
864 kind: KeyEventKind::Press,
865 })];
866 // Use render_frame directly so frame recording is preserved and
867 // FrameState advances naturally between characters.
868 self.render_frame(events, |_| {}, &mut render);
869 }
870 }
871}
872
873/// A single step in a [`TestSequence`].
874///
875/// Holds the event batch to inject, plus a render closure to execute. Created
876/// internally by [`TestSequence::tick`], [`TestSequence::key`],
877/// [`TestSequence::events`], etc.
878struct TestStep<'a> {
879 events: Vec<Event>,
880 render: Box<dyn FnOnce(&mut Context) + 'a>,
881}
882
883/// Builder returned by [`TestBackend::sequence`].
884///
885/// Chain step builders (`tick`, `key`, `type_string`, `events`) and finalize
886/// with [`run`](TestSequence::run). Steps execute sequentially, advancing
887/// `FrameState` between them so focus and hooks evolve naturally without the
888/// caller having to thread state.
889pub struct TestSequence<'a> {
890 backend: &'a mut TestBackend,
891 steps: Vec<TestStep<'a>>,
892}
893
894impl<'a> TestSequence<'a> {
895 /// Append a step that renders without injecting any events.
896 ///
897 /// Equivalent to a single frame tick — useful for letting hooks /
898 /// animations advance between input steps.
899 pub fn tick(mut self, f: impl FnOnce(&mut Context) + 'a) -> Self {
900 self.steps.push(TestStep {
901 events: Vec::new(),
902 render: Box::new(f),
903 });
904 self
905 }
906
907 /// Append a step that fires a single key-press event with no modifiers.
908 pub fn key(mut self, code: KeyCode, f: impl FnOnce(&mut Context) + 'a) -> Self {
909 let events = vec![Event::Key(KeyEvent {
910 code,
911 modifiers: KeyModifiers::NONE,
912 kind: KeyEventKind::Press,
913 })];
914 self.steps.push(TestStep {
915 events,
916 render: Box::new(f),
917 });
918 self
919 }
920
921 /// Append a step that types `s` as a sequence of `KeyCode::Char` events
922 /// **before** invoking `render`.
923 ///
924 /// Unlike [`TestBackend::type_string`], this collapses every typed
925 /// character into a single render step — useful when the per-character
926 /// frame state is not the assertion target. For per-keystroke rendering,
927 /// chain individual `.key(...)` calls.
928 pub fn type_string(mut self, s: &str, f: impl FnOnce(&mut Context) + 'a) -> Self {
929 let events = s
930 .chars()
931 .map(|c| {
932 Event::Key(KeyEvent {
933 code: KeyCode::Char(c),
934 modifiers: KeyModifiers::NONE,
935 kind: KeyEventKind::Press,
936 })
937 })
938 .collect();
939 self.steps.push(TestStep {
940 events,
941 render: Box::new(f),
942 });
943 self
944 }
945
946 /// Append a step with an arbitrary event batch.
947 ///
948 /// Useful for mouse interactions, paste events, or sequences built
949 /// with [`EventBuilder`].
950 pub fn events(mut self, events: Vec<Event>, f: impl FnOnce(&mut Context) + 'a) -> Self {
951 self.steps.push(TestStep {
952 events,
953 render: Box::new(f),
954 });
955 self
956 }
957
958 /// Execute every queued step in order. Returns control to the caller
959 /// (the [`TestBackend`] is borrowed mutably for the lifetime of the
960 /// sequence builder). Use [`TestBackend::buffer`] / `.frames()` /
961 /// `.assert_*` after `run()` returns.
962 pub fn run(self) {
963 let backend = self.backend;
964 for step in self.steps {
965 let TestStep { events, render } = step;
966 // Adapt FnOnce(&mut Context) into the &mut FnMut(&mut Context)
967 // shape that render_frame's internal trampoline already expects.
968 let mut once = Some(render);
969 let f = move |ui: &mut Context| {
970 if let Some(f) = once.take() {
971 f(ui);
972 }
973 };
974 backend.render_frame(events, |_| {}, f);
975 }
976 }
977}
978
979impl std::fmt::Display for TestBackend {
980 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
981 write!(f, "{}", self.to_string_trimmed())
982 }
983}
984
985// ---------------------------------------------------------------------------
986// PtyBackend — end-to-end escape-byte / image-protocol capture (#274)
987// ---------------------------------------------------------------------------
988
989/// Raw bytes emitted for a single rendered frame by [`PtyBackend`].
990///
991/// Unlike [`FrameRecord`] (a glyph/snapshot view of the in-memory
992/// [`Buffer`]), `PtyFrame` holds the *actual* escape-code byte stream the
993/// production flush pipeline produced for the frame: SGR runs, OSC 8
994/// hyperlinks, Sixel (`\x1bPq`), and Kitty graphics (`\x1b_Ga=`).
995///
996/// Since 0.21.0.
997#[cfg(feature = "pty-test")]
998#[derive(Clone, Debug)]
999pub struct PtyFrame {
1000 /// Raw bytes emitted for this frame (SGR runs, OSC 8, Sixel, Kitty).
1001 pub raw: Vec<u8>,
1002}
1003
1004/// Drives the *real* [`crate::run`] flush pipeline into an in-process byte
1005/// sink, so escape-code / color-depth / image-protocol output is asserted
1006/// end-to-end — the byte/protocol tier that [`TestBackend`]'s buffer-only
1007/// model deliberately cannot reach (see `tests/visual_snapshots.rs`).
1008///
1009/// Each [`render`](PtyBackend::render) constructs a fresh fullscreen
1010/// `Terminal` whose sink is a captured `Vec<u8>` (no real TTY, no raw mode),
1011/// runs one frame through the same [`crate::frame_owned`] entry point the
1012/// production loop uses, and captures the emitted bytes. Because the previous
1013/// frame buffer starts empty, every frame emits a complete first-paint diff —
1014/// fully deterministic and reproducible on a headless CI runner.
1015///
1016/// This type is gated behind the dev-only `pty-test` feature and is **not**
1017/// present in a default build.
1018///
1019/// Since 0.21.0.
1020///
1021/// # Example
1022///
1023/// ```no_run
1024/// # #[cfg(feature = "pty-test")]
1025/// # {
1026/// use slt::{Color, PtyBackend};
1027///
1028/// let mut pb = PtyBackend::new(10, 1);
1029/// pb.render(|ui| {
1030/// ui.text("x").fg(Color::Red).bold();
1031/// });
1032/// // The real flush pipeline emitted an SGR sequence for the styled glyph.
1033/// pb.assert_emits("\u{1b}[");
1034/// # }
1035/// ```
1036#[cfg(feature = "pty-test")]
1037pub struct PtyBackend {
1038 width: u32,
1039 height: u32,
1040 color_depth: crate::style::ColorDepth,
1041 state: crate::AppState,
1042 config: RunConfig,
1043 frames: Vec<PtyFrame>,
1044}
1045
1046#[cfg(feature = "pty-test")]
1047impl PtyBackend {
1048 /// Create a PTY capture backend with the given terminal dimensions.
1049 ///
1050 /// Defaults to [`ColorDepth::TrueColor`](crate::ColorDepth::TrueColor);
1051 /// override with [`with_color_depth`](PtyBackend::with_color_depth).
1052 pub fn new(width: u32, height: u32) -> Self {
1053 Self {
1054 width,
1055 height,
1056 color_depth: crate::style::ColorDepth::TrueColor,
1057 state: crate::AppState::new(),
1058 config: RunConfig::default(),
1059 frames: Vec::new(),
1060 }
1061 }
1062
1063 /// Set the [`ColorDepth`](crate::ColorDepth) the flush pipeline encodes
1064 /// SGR colors with (e.g. truecolor vs 256-color). Returns `self` for
1065 /// chaining.
1066 pub fn with_color_depth(mut self, depth: crate::style::ColorDepth) -> Self {
1067 self.color_depth = depth;
1068 self
1069 }
1070
1071 /// Render one frame through the real `Terminal` flush pipeline, capturing
1072 /// the emitted bytes. Returns the just-captured [`PtyFrame`].
1073 pub fn render(&mut self, f: impl FnOnce(&mut Context)) -> &PtyFrame {
1074 self.render_with_events(Vec::new(), f)
1075 }
1076
1077 /// Render one frame with injected input `events`, capturing the emitted
1078 /// bytes. Returns the just-captured [`PtyFrame`].
1079 pub fn render_with_events(
1080 &mut self,
1081 events: Vec<Event>,
1082 f: impl FnOnce(&mut Context),
1083 ) -> &PtyFrame {
1084 let mut term =
1085 crate::terminal::Terminal::with_sink(self.width, self.height, self.color_depth);
1086 let mut once = Some(f);
1087 let mut render = move |ui: &mut Context| {
1088 if let Some(f) = once.take() {
1089 f(ui);
1090 }
1091 };
1092 // Drive the production single-frame entry point. The captured-sink
1093 // Terminal routes every byte through flush_buffer_diff /
1094 // apply_style_delta / Sixel / Kitty exactly as a real terminal would.
1095 let _ = crate::frame_owned(
1096 &mut term,
1097 &mut self.state,
1098 &self.config,
1099 events,
1100 &mut render,
1101 );
1102 let raw = term.take_sink_bytes();
1103 self.frames.push(PtyFrame { raw });
1104 self.frames.last().expect("frame just pushed")
1105 }
1106
1107 /// Iterate the raw byte stream of every captured frame, oldest first.
1108 pub fn frames_raw(&self) -> impl Iterator<Item = &[u8]> {
1109 self.frames.iter().map(|f| f.raw.as_slice())
1110 }
1111
1112 /// Raw bytes of the most recently rendered frame.
1113 ///
1114 /// Panics if no frame has been rendered yet.
1115 pub fn last_raw(&self) -> &[u8] {
1116 &self.frames.last().expect("no frame rendered").raw
1117 }
1118
1119 /// Assert the last frame's byte stream contains `needle`.
1120 ///
1121 /// Panics with an escaped + hex dump of the emitted bytes on a miss.
1122 pub fn assert_emits(&self, needle: &str) {
1123 let raw = self.last_raw();
1124 if find_subslice(raw, needle.as_bytes()).is_none() {
1125 panic!(
1126 "PtyBackend frame does not emit {:?}.\nEmitted ({} bytes):\n escaped: {}\n hex: {}",
1127 needle,
1128 raw.len(),
1129 escape_bytes(raw),
1130 hex_bytes(raw),
1131 );
1132 }
1133 }
1134
1135 /// Assert the last frame's byte stream does **not** contain `needle`.
1136 ///
1137 /// Panics with an escaped + hex dump on an unexpected hit.
1138 pub fn assert_not_emits(&self, needle: &str) {
1139 let raw = self.last_raw();
1140 if find_subslice(raw, needle.as_bytes()).is_some() {
1141 panic!(
1142 "PtyBackend frame unexpectedly emits {:?}.\nEmitted ({} bytes):\n escaped: {}\n hex: {}",
1143 needle,
1144 raw.len(),
1145 escape_bytes(raw),
1146 hex_bytes(raw),
1147 );
1148 }
1149 }
1150}
1151
1152/// Byte-substring search (no UTF-8 assumption — escape streams are not valid
1153/// UTF-8 in general).
1154#[cfg(feature = "pty-test")]
1155fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1156 if needle.is_empty() {
1157 return Some(0);
1158 }
1159 haystack.windows(needle.len()).position(|w| w == needle)
1160}
1161
1162/// Render a byte slice with non-printable bytes shown as `\xNN` escapes.
1163#[cfg(feature = "pty-test")]
1164fn escape_bytes(bytes: &[u8]) -> String {
1165 let mut s = String::with_capacity(bytes.len());
1166 for &b in bytes {
1167 match b {
1168 0x1b => s.push_str("\\x1b"),
1169 0x20..=0x7e => s.push(b as char),
1170 b'\n' => s.push_str("\\n"),
1171 b'\r' => s.push_str("\\r"),
1172 b'\t' => s.push_str("\\t"),
1173 _ => s.push_str(&format!("\\x{b:02x}")),
1174 }
1175 }
1176 s
1177}
1178
1179/// Render a byte slice as space-separated two-digit hex.
1180#[cfg(feature = "pty-test")]
1181fn hex_bytes(bytes: &[u8]) -> String {
1182 bytes
1183 .iter()
1184 .map(|b| format!("{b:02x}"))
1185 .collect::<Vec<_>>()
1186 .join(" ")
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use super::*;
1192 use crate::event::{KeyEventKind, MouseKind};
1193
1194 /// Regression test for issue #131: `mouse_up` produces `MouseKind::Up(Left)`.
1195 #[test]
1196 fn event_builder_mouse_up_produces_up_event() {
1197 let events = EventBuilder::new().mouse_up(5, 3).build();
1198 assert_eq!(events.len(), 1);
1199 match &events[0] {
1200 Event::Mouse(m) => {
1201 assert!(matches!(m.kind, MouseKind::Up(MouseButton::Left)));
1202 assert_eq!(m.x, 5);
1203 assert_eq!(m.y, 3);
1204 }
1205 _ => panic!("expected mouse event"),
1206 }
1207 }
1208
1209 /// Regression test for issue #131: `drag` produces a drag mouse event.
1210 #[test]
1211 fn event_builder_drag_produces_drag_event() {
1212 let events = EventBuilder::new().drag(10, 5).build();
1213 assert_eq!(events.len(), 1);
1214 match &events[0] {
1215 Event::Mouse(m) => {
1216 assert!(matches!(m.kind, MouseKind::Drag(MouseButton::Left)));
1217 assert_eq!(m.x, 10);
1218 assert_eq!(m.y, 5);
1219 }
1220 _ => panic!("expected mouse event"),
1221 }
1222 }
1223
1224 /// Regression test for issue #131: `key_release` produces a release key event.
1225 #[test]
1226 fn event_builder_key_release_produces_release_event() {
1227 let events = EventBuilder::new().key_release('a').build();
1228 assert_eq!(events.len(), 1);
1229 match &events[0] {
1230 Event::Key(k) => {
1231 assert_eq!(k.code, KeyCode::Char('a'));
1232 assert!(matches!(k.kind, KeyEventKind::Release));
1233 }
1234 _ => panic!("expected key event"),
1235 }
1236 }
1237
1238 /// Regression test for issue #131: focus_gained / focus_lost chain through builder.
1239 #[test]
1240 fn event_builder_focus_events_chaining() {
1241 let events = EventBuilder::new().focus_lost().focus_gained().build();
1242 assert_eq!(events, vec![Event::FocusLost, Event::FocusGained]);
1243 }
1244
1245 /// Issue #261: `key_modifier` builds a single modifier-only key-press event.
1246 #[test]
1247 fn event_builder_key_modifier_produces_modifier_event() {
1248 let events = EventBuilder::new()
1249 .key_modifier(ModifierKey::LeftSuper)
1250 .build();
1251 assert_eq!(events.len(), 1);
1252 match &events[0] {
1253 Event::Key(k) => {
1254 assert_eq!(k.code, KeyCode::Modifier(ModifierKey::LeftSuper));
1255 assert_eq!(k.modifiers, KeyModifiers::NONE);
1256 assert!(matches!(k.kind, KeyEventKind::Press));
1257 }
1258 _ => panic!("expected key event"),
1259 }
1260 }
1261
1262 /// Issue #261: a modifier-only event reaches the frame closure end-to-end.
1263 #[test]
1264 fn modifier_key_event_reaches_frame_closure() {
1265 let mut tb = TestBackend::new(20, 2);
1266 let events = EventBuilder::new()
1267 .key_modifier(ModifierKey::LeftCtrl)
1268 .build();
1269 tb.sequence()
1270 .events(events, |ui| {
1271 if ui.key_code(KeyCode::Modifier(ModifierKey::LeftCtrl)) {
1272 ui.text("ctrl-down");
1273 } else {
1274 ui.text("idle");
1275 }
1276 })
1277 .run();
1278 tb.assert_contains("ctrl-down");
1279 }
1280
1281 // ---- #229 record_frames -------------------------------------------------
1282
1283 #[test]
1284 fn record_frames_disabled_returns_empty_slice() {
1285 let mut tb = TestBackend::new(10, 2);
1286 tb.render(|ui| {
1287 ui.text("hi");
1288 });
1289 assert!(tb.frames().is_empty());
1290 }
1291
1292 #[test]
1293 fn record_frames_captures_each_render() {
1294 let mut tb = TestBackend::new(20, 2).record_frames();
1295 for n in 0..3 {
1296 tb.render(|ui| {
1297 ui.text(format!("frame {n}"));
1298 });
1299 }
1300 assert_eq!(tb.frames().len(), 3);
1301 tb.frames()[0].assert_contains("frame 0");
1302 tb.frames()[1].assert_contains("frame 1");
1303 tb.frames()[2].assert_contains("frame 2");
1304 }
1305
1306 #[test]
1307 fn record_frames_stores_styled_snapshot() {
1308 let mut tb = TestBackend::new(10, 1).record_frames();
1309 tb.render(|ui| {
1310 ui.text("hi").bold();
1311 });
1312 let frame = &tb.frames()[0];
1313 // Styled snapshot should encode the bold modifier somewhere.
1314 assert!(
1315 frame.snapshot.contains("bold"),
1316 "snapshot missing bold marker: {:?}",
1317 frame.snapshot
1318 );
1319 }
1320
1321 #[test]
1322 fn record_frames_idempotent_when_called_twice() {
1323 // record_frames() called twice must not wipe prior history.
1324 let tb = TestBackend::new(10, 1).record_frames();
1325 let mut tb = tb.record_frames();
1326 tb.render(|ui| {
1327 ui.text("a");
1328 });
1329 assert_eq!(tb.frames().len(), 1);
1330 }
1331
1332 #[test]
1333 fn frame_record_to_string_trimmed_drops_trailing_blank_rows() {
1334 let mut tb = TestBackend::new(10, 4).record_frames();
1335 tb.render(|ui| {
1336 ui.text("hello");
1337 });
1338 let frame = &tb.frames()[0];
1339 // The frame should have all 4 rows recorded.
1340 assert_eq!(frame.lines.len(), 4);
1341 // to_string_trimmed drops the trailing empty rows like TestBackend.
1342 let s = frame.to_string_trimmed();
1343 assert!(!s.ends_with('\n'));
1344 assert!(s.starts_with("hello"));
1345 }
1346
1347 // ---- #230 sequence + type_string ----------------------------------------
1348
1349 #[test]
1350 fn sequence_runs_multiple_steps_in_order() {
1351 let mut tb = TestBackend::new(20, 2).record_frames();
1352 tb.sequence()
1353 .tick(|ui| {
1354 ui.text("step-1");
1355 })
1356 .tick(|ui| {
1357 ui.text("step-2");
1358 })
1359 .tick(|ui| {
1360 ui.text("step-3");
1361 })
1362 .run();
1363 assert_eq!(tb.frames().len(), 3);
1364 tb.frames()[0].assert_contains("step-1");
1365 tb.frames()[1].assert_contains("step-2");
1366 tb.frames()[2].assert_contains("step-3");
1367 }
1368
1369 #[test]
1370 fn sequence_key_step_injects_event() {
1371 // We can't easily observe the key event without a stateful widget,
1372 // but we can confirm the sequence builder ran the render closure.
1373 let mut tb = TestBackend::new(20, 2);
1374 tb.sequence()
1375 .key(KeyCode::Esc, |ui| {
1376 ui.text("after-esc");
1377 })
1378 .run();
1379 tb.assert_contains("after-esc");
1380 }
1381
1382 #[test]
1383 fn sequence_type_string_collapses_into_single_step() {
1384 let mut tb = TestBackend::new(20, 2).record_frames();
1385 tb.sequence()
1386 .type_string("abc", |ui| {
1387 ui.text("done");
1388 })
1389 .run();
1390 // Sequence's type_string is one step → one frame, not three.
1391 assert_eq!(tb.frames().len(), 1);
1392 tb.frames()[0].assert_contains("done");
1393 }
1394
1395 #[test]
1396 fn sequence_events_step_takes_arbitrary_batch() {
1397 let mut tb = TestBackend::new(20, 2);
1398 let events = EventBuilder::new()
1399 .key('a')
1400 .key_code(KeyCode::Enter)
1401 .build();
1402 tb.sequence()
1403 .events(events, |ui| {
1404 ui.text("ran");
1405 })
1406 .run();
1407 tb.assert_contains("ran");
1408 }
1409
1410 #[test]
1411 fn type_string_renders_one_frame_per_char() {
1412 let mut tb = TestBackend::new(20, 2).record_frames();
1413 tb.type_string("abc", |ui| {
1414 ui.text("char");
1415 });
1416 assert_eq!(tb.frames().len(), 3);
1417 }
1418
1419 #[test]
1420 fn type_string_handles_empty_input() {
1421 let mut tb = TestBackend::new(20, 2).record_frames();
1422 tb.type_string("", |ui| {
1423 ui.text("never-called");
1424 });
1425 assert_eq!(tb.frames().len(), 0);
1426 }
1427
1428 // ---- #232 negative assertions ------------------------------------------
1429
1430 #[test]
1431 fn assert_not_contains_passes_when_absent() {
1432 let mut tb = TestBackend::new(20, 2);
1433 tb.render(|ui| {
1434 ui.text("hello world");
1435 });
1436 tb.assert_not_contains("error");
1437 }
1438
1439 #[test]
1440 #[should_panic(expected = "Buffer unexpectedly contains")]
1441 fn assert_not_contains_panics_when_present() {
1442 let mut tb = TestBackend::new(20, 2);
1443 tb.render(|ui| {
1444 ui.text("error: fail");
1445 });
1446 tb.assert_not_contains("error");
1447 }
1448
1449 #[test]
1450 fn assert_line_not_contains_passes_when_other_row_has_substring() {
1451 let mut tb = TestBackend::new(20, 3);
1452 tb.render(|ui| {
1453 let _ = ui.col(|ui| {
1454 ui.text("first");
1455 ui.text("second");
1456 });
1457 });
1458 // Line 0 has "first" but not "second".
1459 tb.assert_line_not_contains(0, "second");
1460 }
1461
1462 #[test]
1463 #[should_panic(expected = "Line 0: expected NOT to contain")]
1464 fn assert_line_not_contains_panics_when_present() {
1465 let mut tb = TestBackend::new(20, 1);
1466 tb.render(|ui| {
1467 ui.text("hello");
1468 });
1469 tb.assert_line_not_contains(0, "ello");
1470 }
1471
1472 #[test]
1473 fn assert_empty_line_passes_for_blank_row() {
1474 let mut tb = TestBackend::new(20, 2);
1475 tb.render(|ui| {
1476 ui.text("only-row-0");
1477 });
1478 // Row 1 is untouched after rendering one text → blank.
1479 tb.assert_empty_line(1);
1480 }
1481
1482 #[test]
1483 #[should_panic(expected = "Line 0: expected empty")]
1484 fn assert_empty_line_panics_when_non_blank() {
1485 let mut tb = TestBackend::new(20, 2);
1486 tb.render(|ui| {
1487 ui.text("not-empty");
1488 });
1489 tb.assert_empty_line(0);
1490 }
1491
1492 #[test]
1493 fn assert_style_at_passes_for_matching_style() {
1494 use crate::style::{Color, Modifiers};
1495 let mut tb = TestBackend::new(10, 1);
1496 tb.render(|ui| {
1497 ui.text("x").fg(Color::Red);
1498 });
1499 let expected = Style {
1500 fg: Some(Color::Red),
1501 bg: None,
1502 modifiers: Modifiers::NONE,
1503 ..Style::new()
1504 };
1505 tb.assert_style_at(0, 0, expected);
1506 }
1507
1508 #[test]
1509 #[should_panic(expected = "Style mismatch")]
1510 fn assert_style_at_panics_on_mismatch() {
1511 use crate::style::Color;
1512 let mut tb = TestBackend::new(10, 1);
1513 tb.render(|ui| {
1514 ui.text("x").fg(Color::Red);
1515 });
1516 let expected = Style::new().fg(Color::Blue);
1517 tb.assert_style_at(0, 0, expected);
1518 }
1519
1520 // ---- #283 region queries + snapshot diffing -----------------------------
1521
1522 #[test]
1523 fn find_text_returns_first_match_position() {
1524 let mut tb = TestBackend::new(20, 2);
1525 tb.render(|ui| {
1526 ui.text(" hello");
1527 });
1528 assert_eq!(tb.find_text("hello"), Some((2, 0)));
1529 }
1530
1531 #[test]
1532 fn find_text_scans_rows_top_to_bottom() {
1533 let mut tb = TestBackend::new(20, 3);
1534 tb.render(|ui| {
1535 let _ = ui.col(|ui| {
1536 ui.text("alpha");
1537 ui.text("beta");
1538 });
1539 });
1540 assert_eq!(tb.find_text("beta"), Some((0, 1)));
1541 }
1542
1543 #[test]
1544 fn find_text_returns_none_when_absent() {
1545 let mut tb = TestBackend::new(10, 1);
1546 tb.render(|ui| {
1547 ui.text("present");
1548 });
1549 assert_eq!(tb.find_text("missing"), None);
1550 }
1551
1552 #[test]
1553 fn find_text_empty_needle_is_none() {
1554 // Edge case: an empty needle never yields a position.
1555 let mut tb = TestBackend::new(10, 1);
1556 tb.render(|ui| {
1557 ui.text("x");
1558 });
1559 assert_eq!(tb.find_text(""), None);
1560 }
1561
1562 #[test]
1563 fn region_returns_padded_rectangle() {
1564 let mut tb = TestBackend::new(10, 3);
1565 tb.render(|ui| {
1566 let _ = ui.col(|ui| {
1567 ui.text("ab");
1568 ui.text("cd");
1569 });
1570 });
1571 // Width 3 keeps a trailing space; rows are exactly 3 wide.
1572 assert_eq!(tb.region(0, 0, 3, 2), "ab \ncd ");
1573 }
1574
1575 #[test]
1576 fn region_out_of_bounds_blank_fills() {
1577 // Edge case: a region partly past the buffer pads with spaces.
1578 let mut tb = TestBackend::new(2, 1);
1579 tb.render(|ui| {
1580 ui.text("z");
1581 });
1582 assert_eq!(tb.region(0, 0, 4, 2), "z \n ");
1583 }
1584
1585 #[test]
1586 fn assert_region_passes_for_match() {
1587 let mut tb = TestBackend::new(10, 3);
1588 tb.render(|ui| {
1589 let _ = ui.col(|ui| {
1590 ui.text("ab");
1591 ui.text("cd");
1592 });
1593 });
1594 tb.assert_region(0, 0, 2, 2, "ab\ncd");
1595 }
1596
1597 #[test]
1598 #[should_panic(expected = "Region (0, 0, 2x2) mismatch")]
1599 fn assert_region_panics_on_mismatch() {
1600 let mut tb = TestBackend::new(10, 3);
1601 tb.render(|ui| {
1602 let _ = ui.col(|ui| {
1603 ui.text("ab");
1604 ui.text("cd");
1605 });
1606 });
1607 tb.assert_region(0, 0, 2, 2, "ab\nXY");
1608 }
1609
1610 #[test]
1611 fn assert_styled_contains_passes_for_styled_run() {
1612 use crate::style::{Color, Modifiers};
1613 let mut tb = TestBackend::new(20, 1);
1614 tb.render(|ui| {
1615 ui.text("hi").fg(Color::Red).bold();
1616 });
1617 tb.assert_styled_contains("hi", |s| {
1618 s.fg == Some(Color::Red) && s.modifiers.contains(Modifiers::BOLD)
1619 });
1620 }
1621
1622 #[test]
1623 #[should_panic(expected = "Style predicate failed")]
1624 fn assert_styled_contains_panics_on_style_mismatch() {
1625 use crate::style::Color;
1626 let mut tb = TestBackend::new(20, 1);
1627 tb.render(|ui| {
1628 ui.text("hi").fg(Color::Red);
1629 });
1630 // Content is present but the color predicate fails.
1631 tb.assert_styled_contains("hi", |s| s.fg == Some(Color::Blue));
1632 }
1633
1634 #[test]
1635 #[should_panic(expected = "Buffer does not contain")]
1636 fn assert_styled_contains_panics_when_absent() {
1637 let mut tb = TestBackend::new(20, 1);
1638 tb.render(|ui| {
1639 ui.text("hi");
1640 });
1641 tb.assert_styled_contains("bye", |_| true);
1642 }
1643
1644 #[test]
1645 fn snapshot_is_full_width_and_height() {
1646 let mut tb = TestBackend::new(3, 2);
1647 tb.render(|ui| {
1648 ui.text("ab");
1649 });
1650 // No trailing trim, fixed width, trailing blank row preserved.
1651 assert_eq!(tb.snapshot(), "ab \n ");
1652 }
1653
1654 #[test]
1655 fn assert_snapshot_eq_passes_ignoring_trailing_ws() {
1656 let mut tb = TestBackend::new(5, 2);
1657 tb.render(|ui| {
1658 ui.text("hi");
1659 });
1660 // Expected lacks the padding spaces — trailing whitespace is ignored.
1661 tb.assert_snapshot_eq("hi\n");
1662 }
1663
1664 #[test]
1665 #[should_panic(expected = "Snapshot mismatch")]
1666 fn assert_snapshot_eq_panics_with_diff() {
1667 let mut tb = TestBackend::new(5, 1);
1668 tb.render(|ui| {
1669 ui.text("hi");
1670 });
1671 tb.assert_snapshot_eq("bye");
1672 }
1673
1674 #[test]
1675 fn assert_snapshot_eq_diff_marks_offending_row() {
1676 // Failure-message smoke test: catch the panic and inspect the diff
1677 // markers rather than asserting only on the panic message prefix.
1678 let mut tb = TestBackend::new(5, 2);
1679 tb.render(|ui| {
1680 let _ = ui.col(|ui| {
1681 ui.text("ok");
1682 ui.text("bad");
1683 });
1684 });
1685 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1686 tb.assert_snapshot_eq("ok\nXXX");
1687 }));
1688 let err = result.expect_err("expected snapshot assertion to panic");
1689 let msg = err
1690 .downcast_ref::<String>()
1691 .map(String::as_str)
1692 .or_else(|| err.downcast_ref::<&str>().copied())
1693 .unwrap_or("");
1694 assert!(
1695 msg.contains("- XXX"),
1696 "diff should mark expected row: {msg}"
1697 );
1698 assert!(msg.contains("+ bad"), "diff should mark actual row: {msg}");
1699 // The matching first row is shown without a +/- marker.
1700 assert!(msg.contains(" ok"), "diff should echo matching row: {msg}");
1701 }
1702}