Skip to main content

blockworx/tutorial/
recorder.rs

1//! The level recorder (`blockworx --record-tutorial`): drive the real UI and
2//! capture the gestures as a tutorial level. The recorder taps the world-space
3//! `Interaction` stream the canvas already
4//! computes (plus toolbar switches and editor commits) and distills it into
5//! script steps — a drag becomes one `drag`, a click a `move-to` + `click`,
6//! an editor commit a `type` — with durations normalized from distance rather
7//! than copied from human jitter. Finish snapshots the end document as the
8//! `solution` golden, writes the complete level `.kdl`, and replays it
9//! headlessly on the spot: a recording that doesn't reproduce its own end
10//! state is reported before it ever reaches the registry.
11
12use egui::{Pos2, Rect};
13
14use crate::canvas::Event;
15use crate::document_ng::Document;
16use crate::grid::GRID_SIZE;
17use crate::schema::model as schema;
18use crate::theme::Theme;
19use crate::tools::names::ToolName;
20
21use super::level::Level;
22use crate::script::lowering::Lowering;
23use crate::script::parse::tool_kdl_name;
24use crate::script::session::Session;
25
26/// One distilled script action, in world coordinates; durations and grid
27/// rounding are applied at emission.
28#[derive(Clone, Debug, PartialEq)]
29enum RecStep {
30    ToolClick(ToolName),
31    MoveTo(Pos2),
32    Click { at: Pos2, double: bool },
33    Drag { from: Pos2, to: Pos2 },
34    Type { at: Pos2, text: String },
35}
36
37/// A narrated group: everything recorded between one marker and the next.
38struct Group {
39    key: String,
40    narration: String,
41    steps: Vec<RecStep>,
42}
43
44/// Whether Escape was down on an observed frame — Escape on the frame an
45/// editor closes means the edit was abandoned, not committed.
46#[derive(Clone, Copy, PartialEq, Eq)]
47pub enum EscapeState {
48    Pressed,
49    Idle,
50}
51
52/// The in-place editor as last seen: where it sits (world) and what it holds.
53struct EditWatch {
54    rect: Rect,
55    text: String,
56    escape: EscapeState,
57}
58
59pub struct Recorder {
60    // Panel fields.
61    pub id: String,
62    pub title: String,
63    pub instructions: String,
64    pub out_path: String,
65    pub next_key: String,
66    pub next_narration: String,
67    /// Finish/validation feedback shown in the panel.
68    pub status: String,
69
70    initial_kdl: Option<String>,
71    groups: Vec<Group>,
72    current: Option<Group>,
73    /// Where the distilled cursor last rested (skips no-op `move-to`s).
74    last_pos: Option<Pos2>,
75    drag_start: Option<Pos2>,
76    edit: Option<EditWatch>,
77}
78
79impl Default for Recorder {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl Recorder {
86    pub fn new() -> Self {
87        Self {
88            id: "my-level".into(),
89            title: String::new(),
90            instructions: String::new(),
91            out_path: "recorded_level.kdl".into(),
92            next_key: String::new(),
93            next_narration: String::new(),
94            status: String::new(),
95            initial_kdl: None,
96            groups: Vec::new(),
97            current: None,
98            last_pos: None,
99            drag_start: None,
100            edit: None,
101        }
102    }
103
104    pub fn has_initial(&self) -> bool {
105        self.initial_kdl.is_some()
106    }
107
108    /// Whether gestures are being distilled. The initial document is the
109    /// demo's baseline: until it is captured, everything on the canvas is
110    /// level *setup*, not part of the demo — the recorder stays inert.
111    fn recording(&self) -> bool {
112        self.initial_kdl.is_some()
113    }
114
115    /// Snapshot `doc` as the level's starting document and start recording
116    /// from it. Re-capturing discards any recorded steps — they replayed
117    /// from the old baseline, not this one.
118    pub fn capture_initial(&mut self, doc: &Document) {
119        let had_steps = !self.groups.is_empty() || self.current.is_some();
120        self.initial_kdl = Some(schema::Document::from(doc).to_kdl());
121        self.groups.clear();
122        self.current = None;
123        self.last_pos = None;
124        self.drag_start = None;
125        self.edit = None;
126        self.status = if had_steps {
127            "initial re-captured; recorded steps discarded".into()
128        } else {
129            "initial captured — recording".into()
130        };
131    }
132
133    /// Close the open group and start the next from the panel's marker fields.
134    pub fn start_step(&mut self) {
135        self.commit_group();
136        let n = self.groups.len() + 1;
137        let key = if self.next_key.trim().is_empty() {
138            format!("step-{n}")
139        } else {
140            self.next_key.trim().to_owned()
141        };
142        let narration = if self.next_narration.trim().is_empty() {
143            key.clone()
144        } else {
145            self.next_narration.trim().to_owned()
146        };
147        self.current = Some(Group {
148            key,
149            narration,
150            steps: Vec::new(),
151        });
152        self.next_key.clear();
153        self.next_narration.clear();
154    }
155
156    fn commit_group(&mut self) {
157        if let Some(group) = self.current.take()
158            && !group.steps.is_empty()
159        {
160            self.groups.push(group);
161        }
162    }
163
164    /// Append to the open group, auto-starting one so gestures performed
165    /// before the first marker aren't lost (the author renames `step-1` in
166    /// the file).
167    fn push(&mut self, step: RecStep) {
168        if self.current.is_none() {
169            self.start_step();
170        }
171        if let Some(group) = &mut self.current {
172            group.steps.push(step);
173        }
174    }
175
176    /// A glide to `pos` unless the cursor already rests there (within half a
177    /// cell — clicking twice on one spot shouldn't stutter).
178    fn move_to(&mut self, pos: Pos2) {
179        if self
180            .last_pos
181            .is_none_or(|p| (p - pos).length() > GRID_SIZE / 2.0)
182        {
183            self.push(RecStep::MoveTo(pos));
184        }
185        self.last_pos = Some(pos);
186    }
187
188    /// Distill one frame's canvas interaction.
189    pub fn observe_event(&mut self, event: Option<Event>) {
190        if !self.recording() {
191            return;
192        }
193        match event {
194            Some(Event::Clicked { pos }) => {
195                self.move_to(pos);
196                self.push(RecStep::Click {
197                    at: pos,
198                    double: false,
199                });
200            }
201            Some(Event::DoubleClicked { pos }) => {
202                self.move_to(pos);
203                self.push(RecStep::Click {
204                    at: pos,
205                    double: true,
206                });
207            }
208            Some(Event::DragStarted { pos }) => {
209                self.drag_start = Some(pos);
210            }
211            Some(Event::DragStopped { pos }) => {
212                if let Some(from) = self.drag_start.take() {
213                    self.move_to(from);
214                    self.push(RecStep::Drag { from, to: pos });
215                    self.last_pos = Some(pos);
216                }
217            }
218            // Glide jitter and mid-drag positions are noise; the drag's
219            // endpoints carry the gesture.
220            Some(Event::HoverAt(_) | Event::Dragging { .. }) | None => {}
221        }
222    }
223
224    /// A toolbar tool button was clicked.
225    pub fn observe_tool(&mut self, tool: ToolName) {
226        if self.recording() && tool_kdl_name(tool).is_some() {
227            self.push(RecStep::ToolClick(tool));
228        }
229    }
230
231    /// Watch the in-place editor: `open` is the editor rendered this frame
232    /// (world rect + current text). The frame an editor closes emits a `type`
233    /// step with its final text — unless that frame pressed Escape.
234    pub fn observe_editor(&mut self, open: Option<(Rect, String)>, escape: EscapeState) {
235        if !self.recording() {
236            return;
237        }
238        match (&mut self.edit, open) {
239            (watch @ None, Some((rect, text))) => {
240                *watch = Some(EditWatch { rect, text, escape });
241            }
242            (Some(watch), Some((rect, text))) => {
243                watch.rect = rect;
244                watch.text = text;
245                watch.escape = escape;
246            }
247            (watch @ Some(_), None) => {
248                let Some(closed) = watch.take() else {
249                    return;
250                };
251                if closed.escape == EscapeState::Idle && !closed.text.trim().is_empty() {
252                    self.push(RecStep::Type {
253                        at: closed.rect.center(),
254                        text: closed.text,
255                    });
256                }
257            }
258            (None, None) => {}
259        }
260    }
261
262    /// Snapshot `doc` as the solution, assemble the level file, and replay it
263    /// headlessly to verify the recording reproduces its own end state.
264    /// Returns the file text — plus, on divergence, the document the replay
265    /// actually produced, for diffing against the solution. The caller writes
266    /// the files (the recorder stays filesystem-free for tests and the web
267    /// build).
268    pub fn finish(
269        &mut self,
270        doc: &Document,
271        ctx: &egui::Context,
272        theme: &Theme,
273    ) -> Option<FinishOutput> {
274        self.commit_group();
275        let Some(initial) = self.initial_kdl.clone() else {
276            self.status = "capture the initial document first".into();
277            return None;
278        };
279        if self.groups.is_empty() {
280            self.status = "nothing recorded".into();
281            return None;
282        }
283        let solution = schema::Document::from(doc);
284        let text = emit(self, &initial, &solution);
285        let mut divergence = None;
286        // Parse + replay the emitted file through the real machinery — the
287        // same loop as the player, against a discard painter. `Level::parse`
288        // needs a `'static` source; leaking one recording in a dev session
289        // is fine.
290        match Level::parse(Box::leak(text.clone().into_boxed_str())) {
291            Err(e) => self.status = format!("emitted level fails to parse: {e}"),
292            Ok(level) => match replay(&level, ctx, theme) {
293                Ok(replayed) if replayed == solution => {
294                    self.status = format!("ok: replay reproduces the solution ({})", self.out_path);
295                }
296                Ok(replayed) => {
297                    self.status = format!(
298                        "warning: replay does NOT reproduce the solution — diff \
299                         {0}.replayed.kdl against the solution, or watch it with \
300                         `blockworx --replay {0}`",
301                        self.out_path
302                    );
303                    divergence = Some(replayed.to_kdl());
304                }
305                Err(e) => self.status = format!("replay failed: {e}"),
306            },
307        }
308        Some(FinishOutput {
309            level_text: text,
310            divergence,
311        })
312    }
313}
314
315/// What [`Recorder::finish`] produced: the level file's text and — when the
316/// replay validation failed — the end document the replay actually reached
317/// (in document KDL), for diffing against the recorded solution.
318pub struct FinishOutput {
319    pub level_text: String,
320    pub divergence: Option<String>,
321}
322
323/// What the recorder panel asked the app to do — the two operations that
324/// need the app's document/theme, which the panel itself never touches.
325pub enum PanelRequest {
326    CaptureInitial,
327    Finish,
328}
329
330impl Recorder {
331    /// The recorder's control window. Marker and metadata edits happen in
332    /// place; snapshots round-trip through the returned request.
333    pub fn panel(&mut self, ctx: &egui::Context) -> Option<PanelRequest> {
334        let mut request = None;
335        egui::Window::new("Tutorial recorder")
336            .id(egui::Id::new("tutorial_recorder"))
337            .default_width(320.0)
338            .show(ctx, |ui| {
339                egui::Grid::new("recorder_meta")
340                    .num_columns(2)
341                    .show(ui, |ui| {
342                        ui.label("Id");
343                        ui.text_edit_singleline(&mut self.id);
344                        ui.end_row();
345                        ui.label("Title");
346                        ui.text_edit_singleline(&mut self.title);
347                        ui.end_row();
348                        ui.label("Output");
349                        ui.text_edit_singleline(&mut self.out_path);
350                        ui.end_row();
351                    });
352                ui.label("Instructions");
353                ui.text_edit_multiline(&mut self.instructions);
354                ui.separator();
355                // The capture is the recording boundary: everything before it
356                // is level setup and is deliberately not recorded.
357                ui.horizontal(|ui| {
358                    if ui.button("Capture initial").clicked() {
359                        request = Some(PanelRequest::CaptureInitial);
360                    }
361                    if self.has_initial() {
362                        ui.label("\u{25cf} recording");
363                    } else {
364                        ui.label("setting up (gestures not recorded)");
365                    }
366                });
367                if !self.has_initial() {
368                    ui.small("Arrange the level's starting document, then capture it — recording begins there.");
369                }
370                ui.separator();
371                ui.add_enabled_ui(self.has_initial(), |ui| {
372                    egui::Grid::new("recorder_marker")
373                        .num_columns(2)
374                        .show(ui, |ui| {
375                            ui.label("Step key");
376                            ui.text_edit_singleline(&mut self.next_key);
377                            ui.end_row();
378                            ui.label("Narration");
379                            ui.text_edit_singleline(&mut self.next_narration);
380                            ui.end_row();
381                        });
382                    ui.horizontal(|ui| {
383                        if ui.button("Start step").clicked() {
384                            self.start_step();
385                        }
386                        let done = self.groups.len();
387                        let current = self.current.as_ref().map_or(0, |g| g.steps.len());
388                        ui.label(format!(
389                            "{done} step(s) done, {current} action(s) in this one"
390                        ));
391                    });
392                    ui.separator();
393                    if ui.button("Finish, validate & write").clicked() {
394                        request = Some(PanelRequest::Finish);
395                    }
396                });
397                if !self.status.is_empty() {
398                    ui.label(&self.status);
399                }
400            });
401        request
402    }
403}
404
405/// Replay a level's whole script headlessly inside the live context (discard
406/// painter), returning the end-state projection.
407fn replay(level: &Level, ctx: &egui::Context, theme: &Theme) -> Result<schema::Document, String> {
408    let mut session = Session::new(level.initial_kdl, &level.id);
409    let mut lowering = Lowering::new(&level.script);
410    let discard = egui::Painter::new(
411        ctx.clone(),
412        egui::LayerId::new(egui::Order::Background, egui::Id::new("recorder_replay")),
413        Rect::NOTHING,
414    );
415    loop {
416        let projected = session.projected();
417        match lowering.next(&projected) {
418            Ok(Some(frame)) => session.step(frame, theme, &discard),
419            Ok(None) => break,
420            Err(e) => return Err(format!("step {} names a missing object", e.step)),
421        }
422    }
423    // Deliver a queued editor commit (Enter lands one frame after the text).
424    let idle = session.idle_frame();
425    session.step(idle, theme, &discard);
426    Ok(session.projected())
427}
428
429/// A world position in grid cells, rounded — the coordinates level KDL uses.
430fn cells(pos: Pos2) -> (i32, i32) {
431    (
432        (pos.x / GRID_SIZE).round() as i32,
433        (pos.y / GRID_SIZE).round() as i32,
434    )
435}
436
437fn target(pos: Pos2) -> String {
438    let (x, y) = cells(pos);
439    format!("\"{x},{y}\"")
440}
441
442/// Glide time normalized from distance — brisk but followable, never jittery.
443fn glide_secs(from: Option<Pos2>, to: Pos2) -> f32 {
444    let dist = from.map_or(GRID_SIZE * 4.0, |f| (f - to).length());
445    round1((0.4 + dist / 300.0).clamp(0.5, 1.4))
446}
447
448fn round1(x: f32) -> f32 {
449    (x * 10.0).round() / 10.0
450}
451
452fn kdl_quote(s: &str) -> String {
453    let escaped = s
454        .replace('\\', "\\\\")
455        .replace('"', "\\\"")
456        .replace('\n', "\\n");
457    format!("\"{escaped}\"")
458}
459
460/// Indent a document snippet to sit inside a `{}` block.
461fn indent(kdl: &str, by: &str) -> String {
462    kdl.lines()
463        .map(|l| {
464            if l.is_empty() {
465                String::new()
466            } else {
467                format!("{by}{l}")
468            }
469        })
470        .collect::<Vec<_>>()
471        .join("\n")
472}
473
474/// The camera: the solution's top (sheet) block, which the document pipeline
475/// keeps sized around the content — the same rect the hand-written levels
476/// frame. Falls back to a bounding box over the blocks.
477fn camera(solution: &schema::Document) -> (i32, i32, i32, i32) {
478    if let Some(top) = solution.blocks.iter().find(|b| b.id == solution.top) {
479        let (w, h) = (
480            i32::try_from(top.w).unwrap_or(i32::MAX),
481            i32::try_from(top.h).unwrap_or(i32::MAX),
482        );
483        return (top.x, top.y, w, h);
484    }
485    let xs = || solution.blocks.iter();
486    let x0 = xs().map(|b| b.x).min().unwrap_or(0);
487    let y0 = xs().map(|b| b.y).min().unwrap_or(0);
488    let far = |b: &schema::Block| b.x.saturating_add(i32::try_from(b.w).unwrap_or(i32::MAX));
489    let low = |b: &schema::Block| b.y.saturating_add(i32::try_from(b.h).unwrap_or(i32::MAX));
490    let x1 = xs().map(far).max().unwrap_or(24);
491    let y1 = xs().map(low).max().unwrap_or(20);
492    (x0 - 2, y0 - 2, (x1 - x0) + 4, (y1 - y0) + 4)
493}
494
495/// Assemble the complete level file.
496fn emit(rec: &Recorder, initial: &str, solution: &schema::Document) -> String {
497    use std::fmt::Write;
498    let mut out = String::new();
499    let title = if rec.title.trim().is_empty() {
500        rec.id.clone()
501    } else {
502        rec.title.trim().to_owned()
503    };
504    let _ = writeln!(
505        out,
506        "level {} title={} {{",
507        kdl_quote(rec.id.trim()),
508        kdl_quote(&title)
509    );
510    let _ = writeln!(
511        out,
512        "    instructions {}",
513        kdl_quote(rec.instructions.trim())
514    );
515    let (cx, cy, cw, ch) = camera(solution);
516    let _ = writeln!(out, "    camera x={cx} y={cy} w={cw} h={ch}");
517    let _ = writeln!(
518        out,
519        "    initial {{\n{}\n    }}",
520        indent(initial.trim_end(), "        ")
521    );
522    let _ = writeln!(
523        out,
524        "    solution {{\n{}\n    }}",
525        indent(solution.to_kdl().trim_end(), "        ")
526    );
527    let last_group = rec.groups.len().saturating_sub(1);
528    let mut cursor: Option<Pos2> = None;
529    for (gi, group) in rec.groups.iter().enumerate() {
530        let _ = writeln!(
531            out,
532            "    step key={} en={} {{",
533            kdl_quote(&group.key),
534            kdl_quote(&group.narration)
535        );
536        for step in &group.steps {
537            match step {
538                RecStep::ToolClick(tool) => {
539                    // Unreachable fallback: observe_tool filters unnameable tools.
540                    let name = tool_kdl_name(*tool).unwrap_or("select");
541                    let _ = writeln!(out, "        highlight \"tool:{name}\" secs=1.2");
542                    let _ = writeln!(out, "        click \"tool:{name}\"");
543                    cursor = None;
544                }
545                RecStep::MoveTo(pos) => {
546                    let secs = glide_secs(cursor, *pos);
547                    let _ = writeln!(out, "        move-to {} secs={secs}", target(*pos));
548                    cursor = Some(*pos);
549                }
550                RecStep::Click { at, double } => {
551                    let node = if *double { "double-click" } else { "click" };
552                    let _ = writeln!(out, "        {node} {}", target(*at));
553                    cursor = Some(*at);
554                }
555                RecStep::Drag { from, to } => {
556                    let secs = round1((glide_secs(Some(*from), *to) + 0.2).min(2.0));
557                    let _ = writeln!(
558                        out,
559                        "        drag {} {} secs={secs}",
560                        target(*from),
561                        target(*to)
562                    );
563                    cursor = Some(*to);
564                }
565                RecStep::Type { at, text } => {
566                    let secs = round1((0.4 * text.chars().count() as f32).clamp(0.8, 2.0));
567                    let _ = writeln!(
568                        out,
569                        "        type {} text={} secs={secs}",
570                        target(*at),
571                        kdl_quote(text)
572                    );
573                }
574            }
575        }
576        if gi == last_group {
577            let _ = writeln!(out, "        pause secs=1.0");
578        }
579        let _ = writeln!(out, "    }}");
580    }
581    out.push_str("}\n");
582    out
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use egui::pos2;
589
590    fn px(c: f32) -> f32 {
591        c * GRID_SIZE
592    }
593
594    fn recorder_with_gestures() -> Recorder {
595        let mut rec = Recorder::new();
596        rec.id = "rec-test".into();
597        rec.title = "Recorded".into();
598        rec.instructions = "Do the thing.".into();
599        rec.capture_initial(&Document::default());
600        rec.next_key = "pick-tool".into();
601        rec.next_narration = "Pick the New Block tool".into();
602        rec.start_step();
603        rec.observe_tool(ToolName::NewBlock);
604        rec.next_key = "place".into();
605        rec.next_narration = "Click two corners".into();
606        rec.start_step();
607        rec.observe_event(Some(Event::Clicked {
608            pos: pos2(px(8.0), px(6.0)),
609        }));
610        rec.observe_event(Some(Event::Clicked {
611            pos: pos2(px(16.0), px(13.0)),
612        }));
613        // The editor the placement opened: text grows, then it closes.
614        let edit_rect = Rect::from_center_size(pos2(px(12.0), px(14.0)), egui::vec2(60.0, 20.0));
615        rec.observe_editor(Some((edit_rect, "C".into())), EscapeState::Idle);
616        rec.observe_editor(Some((edit_rect, "CPU".into())), EscapeState::Idle);
617        rec.observe_editor(None, EscapeState::Idle);
618        rec
619    }
620
621    #[test]
622    fn gestures_distill_to_the_expected_steps() {
623        let rec = recorder_with_gestures();
624        let groups = &rec.groups;
625        let current = rec.current.as_ref().unwrap();
626        assert_eq!(groups.len(), 1);
627        assert_eq!(groups[0].key, "pick-tool");
628        assert_eq!(
629            groups[0].steps,
630            vec![RecStep::ToolClick(ToolName::NewBlock)]
631        );
632        assert_eq!(current.key, "place");
633        assert_eq!(
634            current.steps,
635            vec![
636                RecStep::MoveTo(pos2(px(8.0), px(6.0))),
637                RecStep::Click {
638                    at: pos2(px(8.0), px(6.0)),
639                    double: false
640                },
641                RecStep::MoveTo(pos2(px(16.0), px(13.0))),
642                RecStep::Click {
643                    at: pos2(px(16.0), px(13.0)),
644                    double: false
645                },
646                RecStep::Type {
647                    at: pos2(px(12.0), px(14.0)),
648                    text: "CPU".into()
649                },
650            ]
651        );
652    }
653
654    /// The initial document is the recording boundary: level setup performed
655    /// before capturing it must not appear in the script, and re-capturing
656    /// resets the recording to the new baseline.
657    #[test]
658    fn setup_gestures_before_the_initial_capture_are_not_recorded() {
659        let mut rec = Recorder::new();
660        rec.observe_tool(ToolName::NewBlock);
661        rec.observe_event(Some(Event::Clicked {
662            pos: pos2(px(4.0), px(4.0)),
663        }));
664        assert!(rec.groups.is_empty() && rec.current.is_none());
665
666        rec.capture_initial(&Document::default());
667        rec.observe_event(Some(Event::Clicked {
668            pos: pos2(px(8.0), px(6.0)),
669        }));
670        assert_eq!(rec.current.as_ref().unwrap().steps.len(), 2);
671
672        // Re-capturing moves the baseline; the old steps replayed from the
673        // previous one, so they are discarded.
674        rec.capture_initial(&Document::default());
675        assert!(rec.groups.is_empty() && rec.current.is_none());
676    }
677
678    #[test]
679    fn an_escaped_editor_leaves_no_type_step() {
680        let mut rec = Recorder::new();
681        rec.capture_initial(&Document::default());
682        let edit_rect = Rect::from_center_size(pos2(px(4.0), px(4.0)), egui::vec2(60.0, 20.0));
683        rec.observe_editor(Some((edit_rect, "oops".into())), EscapeState::Idle);
684        rec.observe_editor(Some((edit_rect, "oops".into())), EscapeState::Pressed);
685        rec.observe_editor(None, EscapeState::Idle);
686        assert!(rec.current.is_none() && rec.groups.is_empty());
687    }
688
689    #[test]
690    fn a_drag_distills_to_move_and_drag() {
691        let mut rec = Recorder::new();
692        rec.capture_initial(&Document::default());
693        rec.observe_event(Some(Event::DragStarted {
694            pos: pos2(px(10.0), px(6.0)),
695        }));
696        rec.observe_event(Some(Event::Dragging {
697            pos: pos2(px(14.0), px(6.0)),
698            delta: egui::vec2(1.0, 0.0),
699        }));
700        rec.observe_event(Some(Event::DragStopped {
701            pos: pos2(px(18.0), px(6.0)),
702        }));
703        let current = rec.current.as_ref().unwrap();
704        assert_eq!(
705            current.steps,
706            vec![
707                RecStep::MoveTo(pos2(px(10.0), px(6.0))),
708                RecStep::Drag {
709                    from: pos2(px(10.0), px(6.0)),
710                    to: pos2(px(18.0), px(6.0))
711                },
712            ]
713        );
714    }
715
716    /// The emitted file must parse back through the real level parser, with
717    /// the narrated groups and steps intact — the round trip that makes a
718    /// recording a level.
719    #[test]
720    fn emitted_level_round_trips_through_the_parser() {
721        let mut rec = recorder_with_gestures();
722        rec.commit_group();
723        let initial = "top \"b0\"\n\nblock \"b0\" x=0 y=0 w=24 h=20 {\n    title \"sheet\"\n}";
724        let solution = schema::Document::parse_kdl(
725            r#"
726top "b0"
727
728block "b0" x=0 y=0 w=24 h=20 {
729    title "sheet"
730    children "b1"
731}
732
733block "b1" x=8 y=6 w=8 h=7 {
734    title "CPU"
735}
736"#,
737            "rec-test",
738        )
739        .unwrap();
740        let text = emit(&rec, initial, &solution);
741        let level =
742            Level::parse(Box::leak(text.into_boxed_str())).unwrap_or_else(|e| panic!("{e}"));
743        assert_eq!(level.id, "rec-test");
744        assert_eq!(level.steps.len(), 2);
745        assert_eq!(level.steps[0].key, "pick-tool");
746        assert_eq!(level.steps[1].key, "place");
747        assert!(level.initial_kdl.is_some());
748        assert_eq!(
749            level.camera,
750            Rect::from_min_max(
751                crate::script::step::grid_pos(0, 0),
752                crate::script::step::grid_pos(24, 20),
753            )
754        );
755    }
756}