retroglyph_core/testing/conformance.rs
1//! Cross-backend conformance tests for [`Output`](crate::backend::Output), [`Cursor`](crate::backend::Cursor), and [`Input`](crate::backend::Input) (retroglyph#763).
2//!
3//! Each of the five backends in this workspace answers the same handful of obligations
4//! ([`Output::clear`](crate::backend::Output::clear)/[`Output::resize`](crate::backend::Output::resize) resetting internal state, out-of-range [`DrawCell`](crate::backend::DrawCell)
5//! positions, cursor tracking staying in sync with external writes, [`Input::push_event`](crate::backend::Input::push_event)
6//! coalescing consecutive `Mouse(Moved)` events) independently. This module is what holds
7//! those independent answers to a single agreed contract: [`assert_output_contract`],
8//! [`assert_cursor_contract`], and [`assert_input_contract`] each drive a backend through one
9//! facet's obligations and panic on the first violation, so a backend crate wires one of them
10//! into a `#[test]` and gets every future regression in that facet for free.
11//!
12//! # Why not `B: Backend`
13//!
14//! `GlRenderer` deliberately implements neither [`Input`](crate::backend::Input) nor [`Cursor`](crate::backend::Cursor) (a GPU/pixel surface has
15//! no text cursor and never receives external input): a single `B: Backend` bound would make the
16//! harness itself impossible to use, since a bound including `Input + Cursor` could never be
17//! satisfied by every backend that wants only [`assert_output_contract`]. The three entry points
18//! stay separate so a backend opts into exactly the facets it implements.
19//!
20//! # The `Observable` hook, and why it must be a delta
21//!
22//! `Output`/`Cursor` have no shared way to read back "what would actually appear": a terminal
23//! backend has emitted bytes, a pixel backend has a framebuffer, [`Headless`](crate::backend::Headless)
24//! has a [`Grid`](crate::grid::Grid). [`Observable::snapshot`] is the one method a backend
25//! implements to bridge that gap, and every assertion below only ever compares two calls to it
26//! for equality, never interpreting the `u64` any other way.
27//!
28//! That equality only means what it should if `snapshot` returns **what changed since the
29//! previous call**, not the backend's whole history or its whole current state. The assertions
30//! compare two independently-built action sequences that a real user could not tell apart from
31//! this point forward; if `snapshot` hashed everything ever produced (a terminal backend's whole
32//! emitted byte log, say), two sequences of different lengths could never compare equal even when
33//! both are correct, and the assertions would fail on every backend, always, for a reason that has
34//! nothing to do with the obligation under test. A backend whose only observable output is an
35//! appended log implements this by hashing the slice appended since the last call (and advancing
36//! a remembered offset past it). A framebuffer-shaped backend implements it by hashing the
37//! positions that differ from the previous call's content (and remembering the new content for
38//! next time) rather than the whole buffer. Either way, `snapshot` needs its own "since last
39//! call" bookkeeping the production backend has no other reason to carry, which is usually
40//! easiest to add via a small test-only wrapper around the real backend rather than on the
41//! backend type itself; see the `tests` module below for a worked example over
42//! [`Headless`](crate::backend::Headless).
43//!
44//! # What this does not cover
45//!
46//! [`Output::needs_full_frame`](crate::backend::Output::needs_full_frame) only takes effect through
47//! [`Terminal::present`](crate::terminal::Terminal::present) when a backend also returns `true` from
48//! [`Output::composites_layers`](crate::backend::Output::composites_layers) (see that method's docs); a bare `Output` impl has no diffing of
49//! its own to exercise, so that combination is instead pinned by a `Terminal`-level test rather
50//! than by this module.
51
52use crate::backend::{Cursor, CursorStyle, DrawCell, Input, Output};
53use crate::color::Style;
54use crate::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
55use crate::grid::{Pos, Size};
56use crate::tile::Tile;
57use alloc::vec::Vec;
58use core::time::Duration;
59use ixy::HasSize;
60
61/// Hashes `bytes` with FNV-1a (64-bit).
62///
63/// `core::hash::Hasher`/`std::hash::DefaultHasher` are either the wrong shape (no portable digest
64/// guarantee) or unavailable at all under `no_std`, so [`Observable`] implementors get a small,
65/// dependency-free digest instead. Not cryptographic, and not guaranteed stable across
66/// `retroglyph-core` versions: only ever compared within a single test run, never persisted.
67#[must_use]
68pub fn fnv1a(bytes: &[u8]) -> u64 {
69 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
70 const PRIME: u64 = 0x0000_0100_0000_01b3;
71
72 let mut hash = OFFSET_BASIS;
73 for &byte in bytes {
74 hash ^= u64::from(byte);
75 hash = hash.wrapping_mul(PRIME);
76 }
77 hash
78}
79
80/// A backend that can report a digest of what changed since the last call.
81///
82/// See the module docs for why "since the last call", not the whole history or the whole current
83/// state, is the contract every implementation has to meet.
84pub trait Observable: Output {
85 /// A digest of what changed since the previous call (or since construction, for the first
86 /// call).
87 fn snapshot(&mut self) -> u64;
88}
89
90/// One glyph cell with no grapheme text and no tint, for feeding [`Output::draw_layers`](crate::backend::Output::draw_layers).
91const fn cell(pos: Pos, tile: &Tile) -> DrawCell<'_> {
92 DrawCell::new(pos, tile)
93}
94
95/// Draws `tile` at `pos` and flushes.
96fn draw_one<B: Output>(backend: &mut B, pos: Pos, tile: &Tile) -> Result<(), B::Error> {
97 backend.draw_layers(core::iter::once(cell(pos, tile)))?;
98 backend.flush()
99}
100
101/// Unwraps an `Output` call's result, panicking with the error rather than requiring every call
102/// site above to spell out its own `expect`.
103fn expect<T, E: core::fmt::Debug>(result: Result<T, E>) -> T {
104 match result {
105 Ok(value) => value,
106 Err(error) => panic!("backend Output call failed: {error:?}"),
107 }
108}
109
110/// Drives `B` through [`Output`](crate::backend::Output)'s obligations: `make` must return a fresh backend sized to the
111/// requested [`Size`](crate::grid::Size), with no cells drawn yet.
112///
113/// # Panics
114///
115/// Panics on the first obligation `B` violates, or if any `Output` call returns `Err`
116/// (`Observable` backends in this workspace are all infallible; a fallible one that fails here
117/// has a bug this harness cannot usefully attribute, since it isn't the obligation under test).
118pub fn assert_output_contract<B: Observable, F: FnMut(Size) -> B>(mut make: F) {
119 let size = Size::new(4, 3);
120 let a = Tile::new('A', Style::new());
121 let b = Tile::new('B', Style::new());
122
123 // `resize(size)` updates `size()`.
124 {
125 let mut backend = make(size);
126 assert_eq!(
127 backend.size(),
128 size,
129 "a freshly made backend must report the size it was made with"
130 );
131 let grown = Size::new(size.width() + 2, size.height() + 1);
132 backend.resize(grown);
133 assert_eq!(
134 backend.size(),
135 grown,
136 "Output::resize(size) must update what Output::size() reports (retroglyph#763)"
137 );
138 }
139
140 // `clear()` resets diff state so an identical redraw repaints.
141 {
142 let mut backend = make(size);
143 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
144 let first_paint = backend.snapshot();
145 expect(backend.clear());
146 let _ = backend.snapshot(); // Not asserted on; only advances the "since last call" point.
147 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
148 let second_paint = backend.snapshot();
149 assert_eq!(
150 second_paint, first_paint,
151 "drawing identical content after clear() must repaint it, not silently skip it \
152 because it matches an internal shadow copy from before the clear (retroglyph#763)"
153 );
154 }
155
156 // `clear()` leaves no stale secondary state (SGR/damage/sprite state, etc).
157 {
158 let mut backend = make(size);
159 expect(draw_one(&mut backend, Pos::new(0, 0), &b));
160 let _ = backend.snapshot();
161 expect(backend.clear());
162 let _ = backend.snapshot();
163 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
164 let after_clear = backend.snapshot();
165 drop(backend); // Some backends (e.g. Crossterm) allow only one live instance at a time.
166
167 let mut fresh = make(size);
168 expect(draw_one(&mut fresh, Pos::new(0, 0), &a));
169 let from_fresh = fresh.snapshot();
170
171 assert_eq!(
172 after_clear, from_fresh,
173 "after clear(), drawing the same content a fresh backend would draw must produce \
174 the same digest; a mismatch means clear() left stale secondary state (tracked SGR \
175 attributes, damage flags, sprite layers, ...) behind (retroglyph#763)"
176 );
177 }
178
179 // `resize(size)` invalidates shadow state.
180 {
181 let mut backend = make(size);
182 expect(draw_one(&mut backend, Pos::new(0, 0), &b));
183 let _ = backend.snapshot();
184 let grown = Size::new(size.width() + 2, size.height() + 1);
185 backend.resize(grown);
186 let _ = backend.snapshot();
187 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
188 let after_resize = backend.snapshot();
189 drop(backend); // Some backends (e.g. Crossterm) allow only one live instance at a time.
190
191 let mut fresh = make(grown);
192 expect(draw_one(&mut fresh, Pos::new(0, 0), &a));
193 let from_fresh = fresh.snapshot();
194
195 assert_eq!(
196 after_resize, from_fresh,
197 "after resize(size), drawing the same content a fresh backend of the new size would \
198 draw must produce the same digest; a mismatch means resize() left stale shadow \
199 state behind (retroglyph#763)"
200 );
201 }
202
203 // Out-of-range `DrawCell::pos` is silently dropped, not a panic and not sent to the display.
204 {
205 let mut backend = make(size);
206 let far = Pos::new(size.width() + 50, size.height() + 50);
207 expect(draw_one(&mut backend, far, &b));
208 let out_of_range = backend.snapshot();
209 drop(backend); // Some backends (e.g. Crossterm) allow only one live instance at a time.
210
211 // Ground truth: drawing nothing at all. A backend that correctly drops an out-of-range
212 // cell instead of sending it produces the exact same digest as this, since as far as the
213 // display is concerned nothing happened either way.
214 let mut fresh = make(size);
215 expect(fresh.draw_layers(core::iter::empty()));
216 expect(fresh.flush());
217 let nothing_drawn = fresh.snapshot();
218
219 assert_eq!(
220 out_of_range, nothing_drawn,
221 "a DrawCell positioned outside size() must not panic and must be silently dropped, \
222 not sent to the display (retroglyph#763)"
223 );
224 }
225}
226
227/// Drives `B` through [`Cursor`](crate::backend::Cursor)'s tracked-cursor obligation.
228///
229/// External writes (an app calling [`Cursor::set_cursor_position`](crate::backend::Cursor::set_cursor_position) between two draws) must not
230/// desync a backend's internal cursor tracking from where the cursor actually is (retroglyph#713).
231///
232/// # Panics
233///
234/// Panics if the tracked cursor desyncs, or if any `Output` call returns `Err`.
235pub fn assert_cursor_contract<B: Observable + Cursor, F: FnMut(Size) -> B>(mut make: F) {
236 let size = Size::new(5, 1);
237 let a = Tile::new('A', Style::new());
238 let c = Tile::new('C', Style::new());
239 let b = Tile::new('B', Style::new());
240
241 // Reference: the cursor only ever moves through ordinary draws, never through the `Cursor`
242 // facet, so reaching position (1, 0) for the final draw here always correctly requires
243 // whatever cursor-move a backend uses to get there. `c`, drawn and left at (4, 0), plays no
244 // further part once its own delta is discarded below: it only exists so this run's *shape*
245 // (two draws, then a third at a position that needs a move) matches the `Cursor`-facet run
246 // next, without the two runs needing to agree on anything drawn earlier.
247 let mut reference = make(size);
248 expect(draw_one(&mut reference, Pos::new(0, 0), &a));
249 expect(draw_one(&mut reference, Pos::new(4, 0), &c));
250 let _ = reference.snapshot();
251 expect(draw_one(&mut reference, Pos::new(1, 0), &b));
252 let reference_delta = reference.snapshot();
253 drop(reference); // Some backends (e.g. Crossterm) allow only one live instance at a time.
254
255 // Same final draw, but the intervening move to column 4 goes through
256 // `Cursor::set_cursor_position` instead of a draw. A backend that doesn't resync its own
257 // tracked cursor on that call produces a different (missing the move) digest here than the
258 // reference above, because it wrongly believes the cursor is still where the first draw left
259 // it.
260 let mut backend = make(size);
261 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
262 backend.set_cursor_position(Pos::new(4, 0));
263 let _ = backend.flush();
264 let _ = backend.snapshot();
265 expect(draw_one(&mut backend, Pos::new(1, 0), &b));
266 let via_external_write = backend.snapshot();
267
268 assert_eq!(
269 via_external_write, reference_delta,
270 "an external Cursor::set_cursor_position call must keep the backend's own tracked \
271 cursor in sync with reality, the same as an ordinary draw does: the next draw must \
272 still emit whatever cursor-move is needed to reach its position (retroglyph#713)"
273 );
274}
275
276/// Every [`CursorStyle`](crate::backend::CursorStyle) variant, in the order [`Cursor::set_cursor_style`](crate::backend::Cursor::set_cursor_style)'s docs describe.
277const CURSOR_STYLE_VARIANTS: [CursorStyle; 6] = [
278 CursorStyle::BlinkingBlock,
279 CursorStyle::SteadyBlock,
280 CursorStyle::BlinkingUnderline,
281 CursorStyle::SteadyUnderline,
282 CursorStyle::BlinkingBar,
283 CursorStyle::SteadyBar,
284];
285
286/// Drives `B` through [`Cursor::set_cursor_style`]'s obligation: each [`CursorStyle`] variant
287/// must have its own distinct, observable effect (retroglyph#920).
288///
289/// `crossterm` and `terminal-wasm` each map every `CursorStyle` variant to a DECSCUSR parameter
290/// via their own independent `match`, with no shared source of truth between the two; this
291/// assertion doesn't compare backends against each other (their emitted bytes differ by design),
292/// but it does pin, once per backend, that the six variants aren't accidentally collapsed onto
293/// fewer than six distinct behaviors (e.g. two arms sharing a fallthrough).
294///
295/// # Panics
296///
297/// Panics if two distinct `CursorStyle` variants produce the same digest, or if any `Output`
298/// call returns `Err`.
299pub fn assert_cursor_style_contract<B: Observable + Cursor, F: FnMut(Size) -> B>(mut make: F) {
300 let size = Size::new(5, 1);
301
302 let mut backend = make(size);
303 let _ = backend.snapshot(); // Discard whatever construction/resize emitted.
304
305 let mut digests = Vec::with_capacity(CURSOR_STYLE_VARIANTS.len());
306 for style in CURSOR_STYLE_VARIANTS {
307 backend.set_cursor_style(style);
308 let _ = backend.flush();
309 digests.push(backend.snapshot());
310 }
311
312 for (i, &a) in digests.iter().enumerate() {
313 for (j, &b) in digests.iter().enumerate().skip(i + 1) {
314 assert_ne!(
315 a, b,
316 "CursorStyle::{:?} and CursorStyle::{:?} must produce distinct backend effects; \
317 a match arm has collided or fallen through (retroglyph#920)",
318 CURSOR_STYLE_VARIANTS[i], CURSOR_STYLE_VARIANTS[j]
319 );
320 }
321 }
322}
323
324/// Drives `B` through [`Input`](crate::backend::Input)'s coalescing obligation.
325///
326/// A burst of consecutive `Event::Mouse(MouseEventKind::Moved)` pushes must collapse to the
327/// latest one, matching [`coalesces_with`](crate::event::coalesces_with).
328///
329/// # Panics
330///
331/// Panics if the burst does not coalesce to exactly one event, or if that event isn't the last
332/// one pushed.
333pub fn assert_input_contract<B: Input, F: FnMut() -> B>(mut make: F) {
334 const fn moved(x: u16) -> Event {
335 Event::Mouse(MouseEvent {
336 kind: MouseEventKind::Moved,
337 position: Pos::new(x, 0),
338 pixel_position: None,
339 modifiers: KeyModifiers::NONE,
340 })
341 }
342
343 let mut backend = make();
344 for x in 0..32u16 {
345 backend.push_event(moved(x));
346 }
347 assert_eq!(
348 backend.poll_event(Duration::ZERO),
349 Some(moved(31)),
350 "a burst of consecutive Mouse(Moved) pushes must coalesce to the latest one (retroglyph#763)"
351 );
352 assert_eq!(
353 backend.poll_event(Duration::ZERO),
354 None,
355 "the coalesced burst must have collapsed to exactly one queued event"
356 );
357
358 // A non-`Moved` event between two `Moved` bursts must not itself be swallowed.
359 backend.push_event(moved(0));
360 backend.push_event(Event::Mouse(MouseEvent {
361 kind: MouseEventKind::Down(MouseButton::Left),
362 position: Pos::new(0, 0),
363 pixel_position: None,
364 modifiers: KeyModifiers::NONE,
365 }));
366 backend.push_event(moved(1));
367 assert!(matches!(
368 backend.poll_event(Duration::ZERO),
369 Some(Event::Mouse(MouseEvent {
370 kind: MouseEventKind::Moved,
371 ..
372 }))
373 ));
374 assert!(matches!(
375 backend.poll_event(Duration::ZERO),
376 Some(Event::Mouse(MouseEvent {
377 kind: MouseEventKind::Down(MouseButton::Left),
378 ..
379 }))
380 ));
381 assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(1)));
382 assert_eq!(backend.poll_event(Duration::ZERO), None);
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::backend::Headless;
389 use alloc::string::String;
390
391 #[test]
392 fn fnv1a_is_deterministic_and_input_sensitive() {
393 assert_eq!(fnv1a(b"retroglyph"), fnv1a(b"retroglyph"));
394 assert_ne!(fnv1a(b"retroglyph"), fnv1a(b"retroglyph!"));
395 assert_ne!(fnv1a(b""), fnv1a(b"\0"));
396 }
397
398 /// Wraps [`Headless`](crate::backend::Headless) so [`Observable::snapshot`] hashes only what changed since the
399 /// previous call, per the module docs. `Headless` is framebuffer-shaped (a `Grid`, replaced
400 /// rather than appended to), so "changed" means "differs from the view remembered from the
401 /// previous call": this remembers [`Headless::format_view`](crate::backend::Headless::format_view)'s output and hashes only the
402 /// `(index, char)` pairs that differ from it, rather than the whole view every time.
403 struct HeadlessObserver {
404 backend: Headless,
405 previous: String,
406 }
407
408 impl HeadlessObserver {
409 fn new(width: u16, height: u16) -> Self {
410 let backend = Headless::new(width, height);
411 let previous = backend.format_view();
412 Self { backend, previous }
413 }
414 }
415
416 impl Output for HeadlessObserver {
417 type Error = core::convert::Infallible;
418
419 fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
420 where
421 I: Iterator<Item = DrawCell<'a>>,
422 {
423 self.backend.draw_layers(content)
424 }
425
426 fn flush(&mut self) -> Result<(), Self::Error> {
427 self.backend.flush()
428 }
429
430 fn size(&self) -> Size {
431 self.backend.size()
432 }
433
434 fn clear(&mut self) -> Result<(), Self::Error> {
435 self.backend.clear()
436 }
437
438 fn resize(&mut self, size: Size) {
439 self.backend.resize(size);
440 }
441 }
442
443 impl Cursor for HeadlessObserver {
444 fn set_cursor_visible(&mut self, visible: bool) {
445 self.backend.set_cursor_visible(visible);
446 }
447
448 fn set_cursor_position(&mut self, position: Pos) {
449 self.backend.set_cursor_position(position);
450 }
451 }
452
453 impl Observable for HeadlessObserver {
454 fn snapshot(&mut self) -> u64 {
455 let current = self.backend.format_view();
456 let mut hash = fnv1a(b"headless-diff");
457 for (index, (was, now)) in self.previous.chars().zip(current.chars()).enumerate() {
458 if was != now {
459 hash ^= fnv1a(&(index as u64).to_ne_bytes());
460 hash ^= fnv1a(&(now as u32).to_ne_bytes());
461 }
462 }
463 self.previous = current;
464 hash
465 }
466 }
467
468 #[test]
469 fn headless_satisfies_the_output_contract() {
470 assert_output_contract(|size| HeadlessObserver::new(size.width(), size.height()));
471 }
472
473 #[test]
474 fn headless_satisfies_the_cursor_contract() {
475 assert_cursor_contract(|size| HeadlessObserver::new(size.width(), size.height()));
476 }
477
478 #[test]
479 fn headless_satisfies_the_input_contract() {
480 assert_input_contract(|| Headless::new(10, 10));
481 }
482}