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 backend 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 slots
37//! that differ from the previous call's content, which is what [`SlotDelta`] is for; see the
38//! `tests` module below for a worked example over [`Headless`](crate::backend::Headless).
39//!
40//! # Coverage
41//!
42//! Not every backend wires every entry point: [`assert_output_contract`]/[`assert_cursor_contract`]
43//! need [`Observable`], which some backends have no test-only reason to implement, and
44//! [`assert_input_contract`] binds only backends where [`Input::push_event`](crate::backend::Input::push_event)'s coalescing
45//! obligation actually applies (see that method's docs for when it doesn't). As of retroglyph#997:
46//!
47//! | Backend | Output | Cursor | Input |
48//! | --- | --- | --- | --- |
49//! | `Headless` (core) | yes | yes | yes |
50//! | `Crossterm<W>` (crossterm) | yes | yes | exempt (see below) |
51//! | `SoftwareRenderer` (software) | yes | yes | yes |
52//! | `GlRenderer` (gl) | yes | n/a (no `Cursor` impl) | n/a (no `Input` impl) |
53//! | `WindowBackend<P>` (window) | not wired | not wired | yes |
54//! | `TerminalWasm` (terminal-wasm) | yes | yes | yes |
55//!
56//! `Crossterm`'s `pushed_events` queue is deliberately exempt from the coalescing obligation
57//! [`assert_input_contract`] checks: unlike the windowed backends' motion events, which can
58//! arrive at device-polling rate, `pushed_events` is only ever fed one event at a time by a test
59//! harness or a driver replaying an event it already read off the real terminal, so there is no
60//! unbounded-growth failure mode to guard against there (retroglyph#294/retroglyph#768 motivated
61//! the obligation itself). See `Crossterm::push_event`'s doc for the same note closer to the code.
62//!
63//! # What this does not cover
64//!
65//! [`Output::compositing`](crate::backend::Output::compositing)'s `PixelLayered { needs_full_frame }` dispatch only takes effect through
66//! [`Terminal::present`](crate::terminal::Terminal::present); a bare `Output` impl has no diffing of
67//! its own to exercise, so that is instead pinned by a `Terminal`-level test rather than by this
68//! module.
69
70use crate::backend::{Cursor, CursorStyle, DrawCell, Input, Output};
71use crate::color::Style;
72use crate::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
73use crate::grid::{HasSize, Pos, Size};
74use crate::tile::Tile;
75use alloc::vec::Vec;
76use core::time::Duration;
77
78/// Hashes `bytes` with FNV-1a (64-bit).
79///
80/// `core::hash::Hasher`/`std::hash::DefaultHasher` are either the wrong shape (no portable digest
81/// guarantee) or unavailable at all under `no_std`, so [`Observable`] implementors get a small,
82/// dependency-free digest instead. Not cryptographic, and not guaranteed stable across
83/// `retroglyph-core` versions: only ever compared within a single test run, never persisted.
84#[must_use]
85pub fn fnv1a(bytes: &[u8]) -> u64 {
86 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
87 const PRIME: u64 = 0x0000_0100_0000_01b3;
88
89 let mut hash = OFFSET_BASIS;
90 for &byte in bytes {
91 hash ^= u64::from(byte);
92 hash = hash.wrapping_mul(PRIME);
93 }
94 hash
95}
96
97/// Per-slot "changed since last call" bookkeeping for a framebuffer-shaped [`Observable`].
98///
99/// A framebuffer-shaped backend (a `Grid`, a layer list, anything replaced rather than appended
100/// to) can't hash its slice-since-last-time the way an append-log backend can, because there is
101/// no new slice: the whole buffer is rewritten in place. `SlotDelta` remembers the previous call's
102/// per-slot digests and XORs in only the ones that changed, plus a slot-count term so a
103/// shrink-then-grow back to the same per-slot content doesn't hash identically to no change at
104/// all (that term is also what a plain `zip` of previous/current would silently drop on a resize).
105#[derive(Default)]
106pub struct SlotDelta {
107 previous: Vec<u64>,
108}
109
110impl SlotDelta {
111 /// Starts with no remembered previous call, so the first [`Self::digest`] call hashes every
112 /// slot in `current` as changed.
113 #[must_use]
114 pub fn new() -> Self {
115 Self::default()
116 }
117
118 /// Hashes the slots of `current` that differ from the previous call's, salted with `salt` so
119 /// callers sharing one [`fnv1a`] namespace don't collide, and remembers `current` for next
120 /// time.
121 ///
122 /// XOR composition is order-independent for unique slot indices, so it does not matter that
123 /// the per-slot terms below are accumulated in iteration order.
124 pub fn digest(&mut self, salt: &[u8], current: impl IntoIterator<Item = u64>) -> u64 {
125 let current: Vec<u64> = current.into_iter().collect();
126 let mut hash = fnv1a(salt);
127 for (index, (was, now)) in self.previous.iter().zip(current.iter()).enumerate() {
128 if was != now {
129 hash ^= fnv1a(&(index as u64).to_ne_bytes());
130 hash ^= fnv1a(&now.to_ne_bytes());
131 }
132 }
133 hash ^= fnv1a(&(current.len() as u64).to_ne_bytes());
134 self.previous = current;
135 hash
136 }
137}
138
139/// A backend that can report a digest of what changed since the last call.
140///
141/// See the module docs for why "since the last call", not the whole history or the whole current
142/// state, is the contract every implementation has to meet.
143pub trait Observable: Output {
144 /// A digest of what changed since the previous call (or since construction, for the first
145 /// call).
146 fn snapshot(&mut self) -> u64;
147}
148
149/// One glyph cell with no grapheme text and no tint, for feeding [`Output::draw_layers`](crate::backend::Output::draw_layers).
150const fn cell(pos: Pos, tile: &Tile) -> DrawCell<'_> {
151 DrawCell::new(pos, tile)
152}
153
154/// Draws `tile` at `pos` and flushes.
155fn draw_one<B: Output>(backend: &mut B, pos: Pos, tile: &Tile) -> Result<(), B::Error> {
156 backend.draw_layers(core::iter::once(cell(pos, tile)))?;
157 backend.flush()
158}
159
160/// Unwraps an `Output` call's result, panicking with the error rather than requiring every call
161/// site above to spell out its own `expect`.
162fn expect<T, E: core::fmt::Debug>(result: Result<T, E>) -> T {
163 match result {
164 Ok(value) => value,
165 Err(error) => panic!("backend Output call failed: {error:?}"),
166 }
167}
168
169/// Drives `B` through [`Output`](crate::backend::Output)'s obligations: `make` must return a fresh backend sized to the
170/// requested [`Size`](crate::grid::Size), with no cells drawn yet.
171///
172/// # Panics
173///
174/// Panics on the first obligation `B` violates, or if any `Output` call returns `Err`
175/// (`Observable` backends in this workspace are all infallible; a fallible one that fails here
176/// has a bug this harness cannot usefully attribute, since it isn't the obligation under test).
177pub fn assert_output_contract<B: Observable, F: FnMut(Size) -> B>(mut make: F) {
178 let size = Size::new(4, 3);
179 let a = Tile::new('A', Style::new());
180 let b = Tile::new('B', Style::new());
181
182 // `resize(size)` updates `size()`.
183 {
184 let mut backend = make(size);
185 assert_eq!(
186 backend.size(),
187 size,
188 "a freshly made backend must report the size it was made with"
189 );
190 let grown = Size::new(size.width() + 2, size.height() + 1);
191 backend.resize(grown);
192 assert_eq!(
193 backend.size(),
194 grown,
195 "Output::resize(size) must update what Output::size() reports (retroglyph#763)"
196 );
197 }
198
199 // `clear()` resets diff state so an identical redraw repaints.
200 {
201 let mut backend = make(size);
202 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
203 let first_paint = backend.snapshot();
204 expect(backend.clear());
205 let _ = backend.snapshot(); // Not asserted on; only advances the "since last call" point.
206 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
207 let second_paint = backend.snapshot();
208 assert_eq!(
209 second_paint, first_paint,
210 "drawing identical content after clear() must repaint it, not silently skip it \
211 because it matches an internal shadow copy from before the clear (retroglyph#763)"
212 );
213 }
214
215 // `clear()` leaves no stale secondary state (SGR/damage/sprite state, etc).
216 {
217 let mut backend = make(size);
218 expect(draw_one(&mut backend, Pos::new(0, 0), &b));
219 let _ = backend.snapshot();
220 expect(backend.clear());
221 let _ = backend.snapshot();
222 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
223 let after_clear = backend.snapshot();
224 drop(backend); // Some backends (e.g. Crossterm) allow only one live instance at a time.
225
226 let mut fresh = make(size);
227 expect(draw_one(&mut fresh, Pos::new(0, 0), &a));
228 let from_fresh = fresh.snapshot();
229
230 assert_eq!(
231 after_clear, from_fresh,
232 "after clear(), drawing the same content a fresh backend would draw must produce \
233 the same digest; a mismatch means clear() left stale secondary state (tracked SGR \
234 attributes, damage flags, sprite layers, ...) behind (retroglyph#763)"
235 );
236 }
237
238 // `resize(size)` invalidates shadow state.
239 {
240 let mut backend = make(size);
241 expect(draw_one(&mut backend, Pos::new(0, 0), &b));
242 let _ = backend.snapshot();
243 let grown = Size::new(size.width() + 2, size.height() + 1);
244 backend.resize(grown);
245 let _ = backend.snapshot();
246 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
247 let after_resize = backend.snapshot();
248 drop(backend); // Some backends (e.g. Crossterm) allow only one live instance at a time.
249
250 let mut fresh = make(grown);
251 expect(draw_one(&mut fresh, Pos::new(0, 0), &a));
252 let from_fresh = fresh.snapshot();
253
254 assert_eq!(
255 after_resize, from_fresh,
256 "after resize(size), drawing the same content a fresh backend of the new size would \
257 draw must produce the same digest; a mismatch means resize() left stale shadow \
258 state behind (retroglyph#763)"
259 );
260 }
261
262 // Out-of-range `DrawCell::pos` is silently dropped, not a panic and not sent to the display.
263 {
264 let mut backend = make(size);
265 let far = Pos::new(size.width() + 50, size.height() + 50);
266 expect(draw_one(&mut backend, far, &b));
267 let out_of_range = backend.snapshot();
268 drop(backend); // Some backends (e.g. Crossterm) allow only one live instance at a time.
269
270 // Ground truth: drawing nothing at all. A backend that correctly drops an out-of-range
271 // cell instead of sending it produces the exact same digest as this, since as far as the
272 // display is concerned nothing happened either way.
273 let mut fresh = make(size);
274 expect(fresh.draw_layers(core::iter::empty()));
275 expect(fresh.flush());
276 let nothing_drawn = fresh.snapshot();
277
278 assert_eq!(
279 out_of_range, nothing_drawn,
280 "a DrawCell positioned outside size() must not panic and must be silently dropped, \
281 not sent to the display (retroglyph#763)"
282 );
283 }
284}
285
286/// Drives `B` through [`Cursor`](crate::backend::Cursor)'s tracked-cursor obligation.
287///
288/// External writes (an app calling [`Cursor::set_cursor_position`](crate::backend::Cursor::set_cursor_position) between two draws) must not
289/// desync a backend's internal cursor tracking from where the cursor actually is (retroglyph#713).
290///
291/// # Panics
292///
293/// Panics if the tracked cursor desyncs, or if any `Output` call returns `Err`.
294pub fn assert_cursor_contract<B: Observable + Cursor, F: FnMut(Size) -> B>(mut make: F) {
295 let size = Size::new(5, 1);
296 let a = Tile::new('A', Style::new());
297 let c = Tile::new('C', Style::new());
298 let b = Tile::new('B', Style::new());
299
300 // Reference: the cursor only ever moves through ordinary draws, never through the `Cursor`
301 // facet, so reaching position (1, 0) for the final draw here always correctly requires
302 // whatever cursor-move a backend uses to get there. `c`, drawn and left at (4, 0), plays no
303 // further part once its own delta is discarded below: it only exists so this run's *shape*
304 // (two draws, then a third at a position that needs a move) matches the `Cursor`-facet run
305 // next, without the two runs needing to agree on anything drawn earlier.
306 let mut reference = make(size);
307 expect(draw_one(&mut reference, Pos::new(0, 0), &a));
308 expect(draw_one(&mut reference, Pos::new(4, 0), &c));
309 let _ = reference.snapshot();
310 expect(draw_one(&mut reference, Pos::new(1, 0), &b));
311 let reference_delta = reference.snapshot();
312 drop(reference); // Some backends (e.g. Crossterm) allow only one live instance at a time.
313
314 // Same final draw, but the intervening move to column 4 goes through
315 // `Cursor::set_cursor_position` instead of a draw. A backend that doesn't resync its own
316 // tracked cursor on that call produces a different (missing the move) digest here than the
317 // reference above, because it wrongly believes the cursor is still where the first draw left
318 // it.
319 let mut backend = make(size);
320 expect(draw_one(&mut backend, Pos::new(0, 0), &a));
321 backend.set_cursor_position(Pos::new(4, 0));
322 let _ = backend.flush();
323 let _ = backend.snapshot();
324 expect(draw_one(&mut backend, Pos::new(1, 0), &b));
325 let via_external_write = backend.snapshot();
326
327 assert_eq!(
328 via_external_write, reference_delta,
329 "an external Cursor::set_cursor_position call must keep the backend's own tracked \
330 cursor in sync with reality, the same as an ordinary draw does: the next draw must \
331 still emit whatever cursor-move is needed to reach its position (retroglyph#713)"
332 );
333}
334
335/// Every [`CursorStyle`](crate::backend::CursorStyle) variant, in the order [`Cursor::set_cursor_style`](crate::backend::Cursor::set_cursor_style)'s docs describe.
336const CURSOR_STYLE_VARIANTS: [CursorStyle; 6] = [
337 CursorStyle::BlinkingBlock,
338 CursorStyle::SteadyBlock,
339 CursorStyle::BlinkingUnderline,
340 CursorStyle::SteadyUnderline,
341 CursorStyle::BlinkingBar,
342 CursorStyle::SteadyBar,
343];
344
345/// Drives `B` through [`Cursor::set_cursor_style`]'s obligation: each [`CursorStyle`] variant
346/// must have its own distinct, observable effect (retroglyph#920).
347///
348/// `crossterm` and `terminal-wasm` each map every `CursorStyle` variant to a DECSCUSR parameter
349/// via their own independent `match`, with no shared source of truth between the two; this
350/// assertion doesn't compare backends against each other (their emitted bytes differ by design),
351/// but it does pin, once per backend, that the six variants aren't accidentally collapsed onto
352/// fewer than six distinct behaviors (e.g. two arms sharing a fallthrough).
353///
354/// # Panics
355///
356/// Panics if two distinct `CursorStyle` variants produce the same digest, or if any `Output`
357/// call returns `Err`.
358pub fn assert_cursor_style_contract<B: Observable + Cursor, F: FnMut(Size) -> B>(mut make: F) {
359 let size = Size::new(5, 1);
360
361 let mut backend = make(size);
362 let _ = backend.snapshot(); // Discard whatever construction/resize emitted.
363
364 let mut digests = Vec::with_capacity(CURSOR_STYLE_VARIANTS.len());
365 for style in CURSOR_STYLE_VARIANTS {
366 backend.set_cursor_style(style);
367 let _ = backend.flush();
368 digests.push(backend.snapshot());
369 }
370
371 for (i, &a) in digests.iter().enumerate() {
372 for (j, &b) in digests.iter().enumerate().skip(i + 1) {
373 assert_ne!(
374 a, b,
375 "CursorStyle::{:?} and CursorStyle::{:?} must produce distinct backend effects; \
376 a match arm has collided or fallen through (retroglyph#920)",
377 CURSOR_STYLE_VARIANTS[i], CURSOR_STYLE_VARIANTS[j]
378 );
379 }
380 }
381}
382
383/// Drives `B` through [`Input`](crate::backend::Input)'s coalescing obligation.
384///
385/// A burst of consecutive `Event::Mouse(MouseEventKind::Moved)` pushes must collapse to the
386/// latest one, matching [`coalesces_with`](crate::event::coalesces_with).
387///
388/// # Panics
389///
390/// Panics if the burst does not coalesce to exactly one event, or if that event isn't the last
391/// one pushed.
392pub fn assert_input_contract<B: Input, F: FnMut() -> B>(mut make: F) {
393 const fn moved(x: u16) -> Event {
394 Event::Mouse(MouseEvent {
395 kind: MouseEventKind::Moved,
396 position: Pos::new(x, 0),
397 pixel_position: None,
398 modifiers: KeyModifiers::NONE,
399 })
400 }
401
402 let mut backend = make();
403 for x in 0..32u16 {
404 backend.push_event(moved(x));
405 }
406 assert_eq!(
407 backend.poll_event(Duration::ZERO),
408 Some(moved(31)),
409 "a burst of consecutive Mouse(Moved) pushes must coalesce to the latest one (retroglyph#763)"
410 );
411 assert_eq!(
412 backend.poll_event(Duration::ZERO),
413 None,
414 "the coalesced burst must have collapsed to exactly one queued event"
415 );
416
417 // A non-`Moved` event between two `Moved` bursts must not itself be swallowed.
418 backend.push_event(moved(0));
419 backend.push_event(Event::Mouse(MouseEvent {
420 kind: MouseEventKind::Down(MouseButton::Left),
421 position: Pos::new(0, 0),
422 pixel_position: None,
423 modifiers: KeyModifiers::NONE,
424 }));
425 backend.push_event(moved(1));
426 assert!(matches!(
427 backend.poll_event(Duration::ZERO),
428 Some(Event::Mouse(MouseEvent {
429 kind: MouseEventKind::Moved,
430 ..
431 }))
432 ));
433 assert!(matches!(
434 backend.poll_event(Duration::ZERO),
435 Some(Event::Mouse(MouseEvent {
436 kind: MouseEventKind::Down(MouseButton::Left),
437 ..
438 }))
439 ));
440 assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(1)));
441 assert_eq!(backend.poll_event(Duration::ZERO), None);
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::backend::Headless;
448
449 #[test]
450 fn fnv1a_is_deterministic_and_input_sensitive() {
451 assert_eq!(fnv1a(b"retroglyph"), fnv1a(b"retroglyph"));
452 assert_ne!(fnv1a(b"retroglyph"), fnv1a(b"retroglyph!"));
453 assert_ne!(fnv1a(b""), fnv1a(b"\0"));
454 }
455
456 /// Wraps [`Headless`](crate::backend::Headless) so [`Observable::snapshot`] hashes only what changed since the
457 /// previous call, per the module docs. `Headless` is framebuffer-shaped (a `Grid`, replaced
458 /// rather than appended to), so this hashes [`Headless::format_view`](crate::backend::Headless::format_view)'s output through
459 /// [`SlotDelta`], one slot per `char`.
460 struct HeadlessObserver {
461 backend: Headless,
462 delta: SlotDelta,
463 }
464
465 impl HeadlessObserver {
466 fn new(width: u16, height: u16) -> Self {
467 let backend = Headless::new(width, height);
468 let mut delta = SlotDelta::new();
469 // Seed `delta` with the blank initial view so the first real `snapshot()` call
470 // diffs against "what a freshly made backend looks like", not against nothing (an
471 // empty `previous` would make every slot of the first draw look changed, even the
472 // ones the caller never touched).
473 delta.digest(
474 b"headless",
475 backend
476 .format_view()
477 .chars()
478 .map(|c| fnv1a(&(c as u32).to_ne_bytes())),
479 );
480 Self { backend, delta }
481 }
482 }
483
484 impl Output for HeadlessObserver {
485 type Error = core::convert::Infallible;
486
487 fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
488 where
489 I: Iterator<Item = DrawCell<'a>>,
490 {
491 self.backend.draw_layers(content)
492 }
493
494 fn flush(&mut self) -> Result<(), Self::Error> {
495 self.backend.flush()
496 }
497
498 fn size(&self) -> Size {
499 self.backend.size()
500 }
501
502 fn clear(&mut self) -> Result<(), Self::Error> {
503 self.backend.clear()
504 }
505
506 fn resize(&mut self, size: Size) {
507 self.backend.resize(size);
508 }
509 }
510
511 impl Cursor for HeadlessObserver {
512 fn set_cursor_visible(&mut self, visible: bool) {
513 self.backend.set_cursor_visible(visible);
514 }
515
516 fn set_cursor_position(&mut self, position: Pos) {
517 self.backend.set_cursor_position(position);
518 }
519 }
520
521 impl Observable for HeadlessObserver {
522 fn snapshot(&mut self) -> u64 {
523 self.delta.digest(
524 b"headless",
525 self.backend
526 .format_view()
527 .chars()
528 .map(|c| fnv1a(&(c as u32).to_ne_bytes())),
529 )
530 }
531 }
532
533 #[test]
534 fn headless_satisfies_the_output_contract() {
535 assert_output_contract(|size| HeadlessObserver::new(size.width(), size.height()));
536 }
537
538 #[test]
539 fn headless_satisfies_the_cursor_contract() {
540 assert_cursor_contract(|size| HeadlessObserver::new(size.width(), size.height()));
541 }
542
543 #[test]
544 fn headless_satisfies_the_input_contract() {
545 assert_input_contract(|| Headless::new(10, 10));
546 }
547}