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 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}