strop-engine 0.24.0

strop editor engine: documents, grammar dispatch, services, sessions — no terminal
Documentation
//! Generated byte-geometry publications against the real gateway (R12):
//! a deterministic LCG proposes arbitrary raw byte positions, texts and
//! batches; the independent model predicts acceptance, bytes and revision;
//! the real `Editor::apply` must agree or the recipe shrinks to a
//! reproducible JSON steps list. No proptest dependency, no environment
//! seed, no filesystem, no sleeps.
//!
//! The generator intentionally proposes invalid Unicode boundaries: those
//! must be REJECTED by the gateway, never clamped into different edits.
use serde::{Deserialize, Serialize};
use strop_core::id::BufferRevision;
use strop_core::{Buffer, Range, Replacement};

use super::model::{self, Document, Event, Geometry, Model};
use crate::editor::transact::ChangeSet;
use crate::editor::Editor;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Input {
    start: u16,
    end: u16,
    text: u8,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Step {
    edits: Vec<Input>,
    stale: bool,
    readonly: bool,
}

fn document(editor: &Editor) -> Document {
    Document {
        id: editor.current(),
        revision: editor.buf().revision(),
        text: editor.buf().text().to_string(),
    }
}

/// Run one recipe against the real gateway and the independent model.
/// Returns the minimized recipe on disagreement.
fn run(recipe: &[Step]) -> Result<(), String> {
    let mut editor = Editor::new_in(Buffer::from_text("aĆ©\r\nšŸ˜€z\n"), "/recorded".into());
    let mut model = Model::default();
    model.step(Event::Open(document(&editor)))?;
    let mut expected = editor.buf().text().to_string();
    for (index, step) in recipe.iter().enumerate() {
        let before = document(&editor);
        let history_before =
            serde_json::to_value(editor.buf().history()).map_err(|error| error.to_string())?;
        editor.buf_mut().readonly = step.readonly;
        let bound = expected.len() + 1;
        let edits: Vec<_> = step
            .edits
            .iter()
            .map(|input| model::Edit {
                start: usize::from(input.start) % bound,
                end: usize::from(input.end) % bound,
                text: ["", "x", "Ć©", "\r\n", "šŸ˜€"][usize::from(input.text) % 5].into(),
            })
            .collect();
        let prediction = model::apply_text(&expected, Geometry::PreEdit, &edits);
        let revision = before.revision;
        // A different revision even when the valid current revision is zero.
        let base = if step.stale {
            BufferRevision::new(
                revision
                    .get()
                    .checked_add(1)
                    .ok_or("recipe revision exhausted")?,
            )
        } else {
            revision
        };
        let changes = ChangeSet {
            edits: edits
                .iter()
                .map(|edit| {
                    Replacement::new(
                        Range {
                            start: (edit.start % bound).into(),
                            end: (edit.end % bound).into(),
                            shape: strop_core::MotionShape::Characterwise { inclusive: false },
                        },
                        edit.text.clone(),
                    )
                })
                .collect(),
            undo_open: false,
        };
        let result = editor.apply(editor.current(), base, changes);
        let should_accept = prediction.is_ok() && !step.stale && !step.readonly;
        if result.is_ok() != should_accept {
            return Err(format!(
                "step {index}: acceptance disagrees with independent geometry (editor {}, oracle {})",
                result.is_ok(),
                should_accept
            ));
        }
        let after = document(&editor);
        if should_accept {
            expected = prediction.expect("accepted geometry");
            if after.text != expected {
                return Err(format!("step {index}: wrong bytes"));
            }
            model.step(Event::Publish {
                document: before.id,
                base: revision,
                next: after.revision,
                geometry: Geometry::PreEdit,
                edits,
                text: after.text.clone(),
            })?;
        } else {
            model.step(Event::Refused {
                before,
                after: after.clone(),
                history_before,
                history_after: serde_json::to_value(editor.buf().history())
                    .map_err(|error| error.to_string())?,
            })?;
        }
        model.step(Event::Observe {
            documents: vec![after],
            panes: editor.panes.iter().map(|pane| pane.doc).collect(),
        })?;
    }
    Ok(())
}

/// Greedy ddmin-style shrink over whole steps; recipes are tiny.
fn minimize(mut recipe: Vec<Step>) -> Vec<Step> {
    let mut width = recipe.len().max(1);
    while width > 0 {
        let mut start = 0;
        while start < recipe.len() {
            let end = (start + width).min(recipe.len());
            let mut candidate = recipe.clone();
            candidate.drain(start..end);
            if run(&candidate).is_err() {
                recipe = candidate;
            } else {
                start += width;
            }
        }
        width /= 2;
    }
    recipe
}

fn next(state: &mut u64) -> u64 {
    *state = state
        .wrapping_mul(6364136223846793005)
        .wrapping_add(1442695040888963407);
    *state
}

#[test]
fn generated_publications_match_text_revision_and_refusal_model() {
    let mut seed = 0x7374_726f_705f_7231_u64;
    for _ in 0..64 {
        let mut recipe = Vec::new();
        for _ in 0..32 {
            let word = next(&mut seed);
            let count = 1 + (word & 1) as usize;
            let mut edits = Vec::new();
            for _ in 0..count {
                let word = next(&mut seed);
                edits.push(Input {
                    start: word as u16,
                    end: (word >> 16) as u16,
                    text: (word >> 32) as u8,
                });
            }
            recipe.push(Step {
                edits,
                stale: word & 0x1f == 0,
                readonly: word & 0x3f == 1,
            });
        }
        if let Err(error) = run(&recipe) {
            let smallest = minimize(recipe);
            panic!(
                "{error}; reproduce with run(serde_json::from_str::<Vec<Step>>(JSON).unwrap()): {}",
                serde_json::to_string(&smallest).unwrap()
            );
        }
    }
}

#[test]
fn adjacent_and_overlapping_regressions_exercise_real_gateway() {
    let adjacent = Step {
        edits: vec![
            Input {
                start: 0,
                end: 1,
                text: 1,
            },
            Input {
                start: 1,
                end: 3,
                text: 2,
            },
        ],
        stale: false,
        readonly: false,
    };
    let overlap = Step {
        edits: vec![
            Input {
                start: 0,
                end: 3,
                text: 1,
            },
            Input {
                start: 1,
                end: 3,
                text: 2,
            },
        ],
        stale: false,
        readonly: false,
    };
    run(&[adjacent, overlap]).unwrap();
}