Skip to main content

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}