Skip to main content

blockworx/tutorial/
runner.rs

1//! Drive the real editing tools headlessly from a lowered script. This backs
2//! the golden-replay test — every level's demo, executed for real, must end in
3//! the level's stored solution — and the tutorial player's video pane.
4//!
5//! The runner injects [`SimFrame`]s below `View`/`compute_interaction`: it
6//! builds each frame's [`Interaction`] directly and calls the active tool's
7//! `widget()` inside a headless `egui::Context` pass. egui is GPU-free until
8//! tessellation, so this runs in plain `cargo test`.
9
10use egui::{Pos2, Vec2};
11
12use crate::canvas::Interaction;
13use crate::canvas::image::ImageRegistry;
14use crate::canvas::painter::Painter;
15use crate::document_ng::{BlockPath, Document};
16use crate::icons::Icons;
17use crate::schema::model as schema;
18use crate::theme::{Style, Theme};
19use crate::tools::tool::{Action, Tool, ToolTrait};
20use crate::tools::{RenameTitle, SelectTool};
21use crate::widget::drawing::{Drawing, finalize_load};
22
23use super::lowering::{Lowering, SimFrame, UnresolvedTarget};
24use super::script::Script;
25
26/// A headless editing session: the same state the app holds per level, driven
27/// by synthetic input instead of a pointer.
28pub struct Runner {
29    ctx: egui::Context,
30    document: Document,
31    path: BlockPath,
32    tool: Tool,
33    theme: Theme,
34    images: std::rc::Rc<std::cell::RefCell<ImageRegistry>>,
35    icons: Icons,
36    /// Enter queued by a committed typing frame, delivered on the next frame
37    /// so the editor sees its buffer before the key.
38    pending_enter: bool,
39}
40
41impl Runner {
42    /// A session at a level's starting point: the initial document (or an
43    /// empty one) with the Select tool armed — exactly how the app loads a
44    /// level. An initial that fails to parse starts the session empty (the
45    /// level parse test catches broken embedded KDL long before this).
46    pub fn new(initial_kdl: Option<&str>, name: &str) -> Self {
47        let document =
48            match initial_kdl.map(|src| crate::document_ng::schema_convert::from_kdl(src, name)) {
49                Some(Ok(mut doc)) => {
50                    finalize_load(&mut doc);
51                    doc
52                }
53                Some(Err(e)) => {
54                    tracing::error!("runner initial for {name} failed to parse: {e:?}");
55                    Document::default()
56                }
57                None => Document::default(),
58            };
59        let ctx = egui::Context::default();
60        // The canvas draws in `CANVAS_FAMILY`, which nothing binds until the
61        // app's fonts are installed; `set_fonts` lands on the *next* pass, so
62        // run an empty one before the first real frame.
63        ctx.set_fonts(crate::font::build_fonts(
64            crate::preferences::FontChoice::default(),
65        ));
66        let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
67        Self {
68            ctx,
69            document,
70            path: BlockPath::empty(),
71            tool: Tool::Select(SelectTool),
72            theme: Theme::default(),
73            images: std::rc::Rc::new(std::cell::RefCell::new(ImageRegistry::default())),
74            icons: Icons::default(),
75            pending_enter: false,
76        }
77    }
78
79    /// Replay a whole script, resolving doc-anchored targets against the
80    /// session's own document as each step begins.
81    pub fn run_script(&mut self, script: &Script) -> Result<(), UnresolvedTarget> {
82        let mut lowering = Lowering::new(script);
83        loop {
84            let projected = self.projected();
85            match lowering.next(&projected)? {
86                Some(frame) => self.step(frame),
87                None => break,
88            }
89        }
90        // A committed typing entry queues Enter for the frame after the
91        // script's last one — deliver it so the edit lands.
92        if self.pending_enter {
93            self.step(SimFrame::default());
94        }
95        Ok(())
96    }
97
98    /// One synthetic frame through the real tool, exactly as `View::show`
99    /// would deliver it: apply a toolbar switch, run `widget()`, then drive
100    /// the in-place editor the tool may have opened.
101    pub fn step(&mut self, frame: SimFrame) {
102        if let Some(name) = frame.switch_tool {
103            self.tool = Tool::from_name(name);
104        }
105        let interaction = Interaction {
106            event: frame.event,
107            lost_focus: false,
108            enter_pressed: std::mem::take(&mut self.pending_enter),
109            tab_pressed: false,
110            escape_pressed: false,
111            delete_pressed: false,
112            shift: false,
113        };
114        let Self {
115            ctx,
116            document,
117            path,
118            tool,
119            theme,
120            images,
121            icons,
122            pending_enter,
123        } = self;
124        let mut action = None;
125        let _ = ctx.clone().run_ui(egui::RawInput::default(), |ui| {
126            let egui_painter = ui.ctx().layer_painter(egui::LayerId::new(
127                egui::Order::Background,
128                egui::Id::new("tutorial_runner"),
129            ));
130            let mut painter = Painter::new(
131                egui_painter,
132                Pos2::ZERO,
133                1.0,
134                Vec2::ZERO,
135                theme.palette().clone(),
136                images.clone(),
137                icons.clone(),
138            );
139            let mut drawing = Drawing::new(document, path);
140            {
141                let mut style = Style::new(theme, &mut painter);
142                action = tool.widget(&mut drawing, &interaction, &mut style);
143            }
144            // The editor seam: a tool that opened an in-place editor surfaces
145            // it here (the on-screen `View` renders a TextEdit from it). Type
146            // by writing the shared buffer; commit by queueing Enter.
147            if let Some(typing) = frame.typing
148                && let Some(edit) = painter.take_edit_text()
149            {
150                let mut buffer = edit.buffer.borrow_mut();
151                buffer.clear();
152                buffer.push_str(typing.text);
153                if typing.commit {
154                    *pending_enter = true;
155                }
156            }
157        });
158        if let Some(action) = action {
159            self.apply(action);
160        }
161    }
162
163    /// The slice of `Action` the tools return mid-gesture. Anything else is a
164    /// chrome-level request (export, pickers, navigation) that a demo script
165    /// has no business triggering — log and drop rather than silently diverge.
166    fn apply(&mut self, action: Action) {
167        match action {
168            Action::SwitchTool(tool) => self.tool = tool,
169            other => {
170                tracing::warn!(
171                    "tutorial runner ignoring non-tool action: {}",
172                    action_name(&other)
173                );
174            }
175        }
176    }
177
178    pub fn projected(&self) -> schema::Document {
179        schema::Document::from(&self.document)
180    }
181
182    pub fn document(&self) -> &Document {
183        &self.document
184    }
185
186    /// Whether the session is mid-rename (used by tests to sanity-check the
187    /// editor seam engaged).
188    pub fn renaming(&self) -> bool {
189        matches!(self.tool, Tool::RenameTitle(RenameTitle::Renaming { .. }))
190    }
191}
192
193fn action_name(action: &Action) -> &'static str {
194    match action {
195        Action::SwitchTool(_) => "SwitchTool",
196        Action::Delete(_) => "Delete",
197        Action::Copy(_) => "Copy",
198        Action::CopyPins(_) => "CopyPins",
199        Action::Cut(_) => "Cut",
200        Action::CutPins(_) => "CutPins",
201        Action::SetPinTags { .. } => "SetPinTags",
202        Action::SetShapeTagHidden { .. } => "SetShapeTagHidden",
203        Action::FlipShapePins(_) => "FlipShapePins",
204        Action::FlipBlockVertical(_) => "FlipBlockVertical",
205        Action::SetBlockLocked { .. } => "SetBlockLocked",
206        Action::Paste(_) => "Paste",
207        Action::ExpandBlock(_) => "ExpandBlock",
208        Action::OpenRolePicker { .. } => "OpenRolePicker",
209        Action::OpenPinTypePicker { .. } => "OpenPinTypePicker",
210        Action::GoUp => "GoUp",
211        Action::NavSelect { .. } => "NavSelect",
212        Action::PathBack => "PathBack",
213        Action::PathForward => "PathForward",
214        Action::Undo => "Undo",
215        Action::Redo => "Redo",
216        Action::Nudge { .. } => "Nudge",
217        Action::ResetView => "ResetView",
218        Action::Export { .. } => "Export",
219        Action::Import => "Import",
220        Action::Reroute(_) => "Reroute",
221        Action::RerouteBlock(_) => "RerouteBlock",
222        Action::OpenTutorial => "OpenTutorial",
223        Action::TutorialLoadLevel(_) => "TutorialLoadLevel",
224        Action::TutorialExit => "TutorialExit",
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::tutorial::level::all_levels;
232
233    /// The keystone property: every level's demo, executed for real through
234    /// the actual tools, ends in exactly the level's stored solution. A demo
235    /// that stops doing what it appears to do fails here with a document diff;
236    /// regenerate the level file's `solution` from the replay output when the
237    /// change is intended.
238    #[test]
239    fn every_level_replays_to_its_solution() {
240        for level in all_levels() {
241            let mut runner = Runner::new(level.initial_kdl, &level.id);
242            runner.run_script(&level.script).unwrap_or_else(|e| {
243                panic!("{}: demo references a missing object: {e:?}", level.id)
244            });
245            let replayed = runner.projected();
246            let mut solution =
247                crate::document_ng::schema_convert::from_kdl(level.solution_kdl, &level.id)
248                    .unwrap_or_else(|e| panic!("{}: solution failed to parse: {e:?}", level.id));
249            finalize_load(&mut solution);
250            let solution = schema::Document::from(&solution);
251            assert_eq!(
252                replayed,
253                solution,
254                "{}: replay diverged from the stored solution.\n-- replayed --\n{}\n-- solution --\n{}",
255                level.id,
256                replayed.to_kdl(),
257                solution.to_kdl()
258            );
259        }
260    }
261
262    /// The placement step must end inside the real rename flow (the New
263    /// Block tool drops into naming) — pinning that the typing seam is
264    /// exercised for real, not skipped.
265    #[test]
266    fn block_placement_drops_into_the_title_editor() {
267        let levels = all_levels();
268        let first = &levels[0];
269        let mut runner = Runner::new(first.initial_kdl, &first.id);
270        runner.run_script(&first.steps[0].script).unwrap();
271        runner.run_script(&first.steps[1].script).unwrap();
272        assert!(
273            runner.renaming(),
274            "placing the block should open its title editor"
275        );
276    }
277}