scosh-core 0.1.0

Portable session state and terminal event core for scosh
Documentation
use scosh_core::{
    AcknowledgeResult, CoreError, Dimensions, InputOutcome, Session, SessionEvent, SessionOptions,
    StateRevision, TerminalCell, TerminalCellStyle, TerminalCursor, TerminalDelta,
    TerminalSnapshot, TerminalState, TerminalStateDelta, TerminalStateRow,
};

fn blank_state(dimensions: Dimensions) -> TerminalState {
    let blank = TerminalCell::new(Vec::new(), TerminalCellStyle::default()).unwrap();
    TerminalState::new(
        dimensions,
        vec![blank; usize::from(dimensions.columns()) * usize::from(dimensions.rows())],
        TerminalCursor {
            column: 0,
            row: 0,
            visible: true,
        },
        false,
        0,
    )
    .unwrap()
}

fn delta(base: StateRevision, revision: StateRevision, state: &TerminalState) -> TerminalDelta {
    TerminalDelta::new(
        base,
        revision,
        TerminalStateDelta::new(
            state.dimensions,
            vec![TerminalStateRow {
                row: 0,
                cells: state.cells.clone(),
            }],
            state.cursor,
            state.alternate,
            state.modes,
        )
        .unwrap(),
    )
    .unwrap()
}

#[test]
fn snapshot_and_contiguous_delta_are_authoritative() {
    let dimensions = Dimensions::new(2, 1).unwrap();
    let mut state = blank_state(dimensions);
    let mut session = Session::new(SessionOptions::default());
    session
        .install_snapshot(TerminalSnapshot::new(StateRevision::new(0), state.clone()).unwrap())
        .unwrap();
    assert!(matches!(
        session.next_event(),
        Some(SessionEvent::Snapshot { .. })
    ));
    assert!(matches!(
        session.next_event(),
        Some(SessionEvent::StatusChanged(_))
    ));

    state.cells[0] = TerminalCell::new(b"x".to_vec(), TerminalCellStyle::default()).unwrap();
    session
        .apply_delta(&delta(StateRevision::new(0), StateRevision::new(1), &state))
        .unwrap();
    let event = session.next_event().unwrap();
    assert!(matches!(event, SessionEvent::Delta { receipt: None, .. }));
    assert_eq!(session.revision(), StateRevision::new(1));
    assert_eq!(session.state().unwrap().cells[0].text, b"x");
}

#[test]
fn a_revision_gap_requests_a_snapshot_without_mutating_state() {
    let dimensions = Dimensions::new(1, 1).unwrap();
    let state = blank_state(dimensions);
    let mut session = Session::new(SessionOptions::default());
    session
        .install_snapshot(TerminalSnapshot::new(StateRevision::new(4), state.clone()).unwrap())
        .unwrap();
    session.drain_events();

    let mut gapped = delta(StateRevision::new(4), StateRevision::new(5), &state);
    gapped.revision = StateRevision::new(6);
    let error = session.apply_delta(&gapped).unwrap_err();
    assert!(matches!(error, CoreError::RevisionGap { .. }));
    assert!(matches!(
        session.next_event(),
        Some(SessionEvent::SnapshotRequired { .. })
    ));
    assert_eq!(session.revision(), StateRevision::new(4));
    assert_eq!(session.state().unwrap(), &state);
}

#[test]
fn reliable_effects_need_one_idempotent_commit() {
    let dimensions = Dimensions::new(1, 1).unwrap();
    let mut state = blank_state(dimensions);
    let mut session = Session::new(SessionOptions::default());
    session
        .install_snapshot(TerminalSnapshot::new(StateRevision::new(0), state.clone()).unwrap())
        .unwrap();
    session.drain_events();

    state.alternate = true;
    session
        .apply_delta(&delta(StateRevision::new(0), StateRevision::new(1), &state))
        .unwrap();
    let receipt = match session.next_event().unwrap() {
        SessionEvent::Delta {
            receipt: Some(receipt),
            ..
        } => receipt,
        event => panic!("unexpected event: {event:?}"),
    };
    assert_eq!(session.acknowledge(receipt), AcknowledgeResult::Committed);
    assert_eq!(session.acknowledge(receipt), AcknowledgeResult::Duplicate);
}

#[test]
fn recovery_supersedes_pending_input_instead_of_replaying_it() {
    let dimensions = Dimensions::new(1, 1).unwrap();
    let mut session = Session::new(SessionOptions::default());
    session
        .install_snapshot(
            TerminalSnapshot::new(StateRevision::new(0), blank_state(dimensions)).unwrap(),
        )
        .unwrap();
    session.drain_events();

    session.submit_input(b"echo hi").unwrap();
    assert!(matches!(
        session.next_event(),
        Some(SessionEvent::InputQueued { .. })
    ));
    session.begin_recovery().unwrap();
    assert!(matches!(
        session.next_event(),
        Some(SessionEvent::InputCompleted {
            outcome: InputOutcome::Superseded,
            ..
        })
    ));
    assert!(session.pending_input_count() == 0);
}

#[test]
fn a_stalled_consumer_is_bounded() {
    let dimensions = Dimensions::new(1, 1).unwrap();
    let mut session = Session::new(SessionOptions { event_capacity: 1 });
    let snapshot = TerminalSnapshot::new(StateRevision::new(0), blank_state(dimensions)).unwrap();
    session.install_snapshot(snapshot).unwrap();
    assert!(matches!(
        session.install_snapshot(
            TerminalSnapshot::new(StateRevision::new(1), blank_state(dimensions)).unwrap()
        ),
        Err(CoreError::ConsumerStalled)
    ));
}

#[test]
fn close_clears_terminal_state_and_pending_input() {
    let dimensions = Dimensions::new(1, 1).unwrap();
    let mut session = Session::new(SessionOptions::default());
    session
        .install_snapshot(
            TerminalSnapshot::new(StateRevision::new(0), blank_state(dimensions)).unwrap(),
        )
        .unwrap();
    session.drain_events();
    session.submit_input(b"secret").unwrap();
    session.close();
    assert_eq!(session.status(), scosh_core::SessionStatus::Closed);
    assert!(session.state().is_none());
    assert_eq!(session.pending_input_count(), 0);
    assert!(session.next_event().is_none());
}