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