Skip to main content

blockworx/tutorial/
reader.rs

1//! Reading a tutorial: a `.bwx` container whose log *is* the demo.
2//!
3//! A tutorial is an ordinary document that was recorded rather than
4//! authored — so there is no tutorial format, no scaffold, and nothing to
5//! keep in step with the editor. What a level file used to declare, the log
6//! already holds: a step is the commit it made, the narration beside it is a
7//! `note` record (D17), and the sections a viewer navigates by are the notes
8//! marked [`Chapter`](crate::store::record::NoteDisplay::Chapter).
9//!
10//! This is the whole substrate/UI contract. The reader answers three
11//! questions — which chapters are there and how long is each, what does the
12//! commit at this rev look like being made, and what is said while it
13//! happens — and the UI half answers none of them for itself. A chapter's
14//! duration in particular is summed here, from [`Tutorial::span`], so the
15//! "40 seconds" on a library card is the time the player will really spend.
16//!
17//! Two answers arrived with the UI half, and both stay here rather than in
18//! the player: what a tutorial *teaches* — a `teaches/<tool>` tag, which is
19//! the tutorial's own log saying what it is about — and the document a
20//! timeline is depicted against, which nothing but a fold can produce.
21
22use core::time::Duration;
23use std::cell::RefCell;
24use std::path::Path;
25
26use blockworx_doc::document::{DocIndex, Document};
27use blockworx_doc::repo::Repo;
28use blockworx_doc::rev::Rev;
29
30use crate::choreography::{Timeline, synthesize};
31use crate::store::container::ContainerError;
32use crate::store::handle::Store;
33use crate::store::notes::Note;
34use crate::store::record::NoteDisplay;
35use crate::tools::names::{self, ToolName};
36
37/// What a tag's name has to begin with to be a claim about teaching. The
38/// rest of it is a `ToolName::command_name` spelling.
39pub const TEACHES: &str = "teaches/";
40
41/// How long a rev that depicts nothing but says something holds on screen —
42/// what the level format used to spell `pause`. One number, in the substrate,
43/// so a chapter's advertised length and the player's clock cannot disagree.
44pub const DWELL: Duration = Duration::from_millis(1_400);
45
46/// One section of a tutorial: where it begins, what it is called, and how
47/// long it plays for.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct Chapter {
50    pub at: Rev,
51    pub title: String,
52    pub duration: Duration,
53}
54
55/// A tutorial container, opened for playback.
56pub struct Tutorial {
57    name: String,
58    chapters: Vec<Chapter>,
59    store: Store,
60    /// The last fold this reader made. A player walks the log forwards, so
61    /// carrying one document forward turns a per-commit prefix fold into a
62    /// per-commit `try_apply`; a rev asked for out of order simply re-folds
63    /// its prefix.
64    folded: RefCell<Folded>,
65}
66
67struct Folded {
68    at: Rev,
69    document: Document,
70}
71
72impl Tutorial {
73    /// Open the container at `root`.
74    ///
75    /// Read-only in the honest sense — [`Store::reading`] never claims the
76    /// lock — because playing a tutorial must not cost anyone the right to
77    /// write the container it is playing.
78    ///
79    /// # Errors
80    /// As [`Store::reading`]: there is no container at `root`, or its log
81    /// cannot be read.
82    pub fn open(root: &Path) -> Result<Self, ContainerError> {
83        let name = root
84            .file_stem()
85            .unwrap_or_default()
86            .to_string_lossy()
87            .into_owned();
88        let mut tutorial = Self {
89            name,
90            chapters: Vec::new(),
91            store: Store::reading(root)?,
92            folded: RefCell::new(Folded {
93                at: Rev::ZERO,
94                document: Document::default(),
95            }),
96        };
97        tutorial.chapters = tutorial.sections();
98        Ok(tutorial)
99    }
100
101    /// What this tutorial is called: the container's own name (D20).
102    pub fn name(&self) -> &str {
103        &self.name
104    }
105
106    pub fn chapters(&self) -> &[Chapter] {
107        &self.chapters
108    }
109
110    /// The log this tutorial is, in play order.
111    pub fn revs(&self) -> impl Iterator<Item = Rev> {
112        revs(Rev::ZERO.next(), self.store.repo().rev().next())
113    }
114
115    /// The commit at `rev`, depicted: synthesized against the document as it
116    /// stood at `rev - 1`. `None` for a rev this log does not hold, or a
117    /// prefix this build will not fold.
118    pub fn timeline(&self, rev: Rev) -> Option<Timeline> {
119        let pre = rev.prev()?;
120        let log = self.store.repo().log();
121        let commit = log.get(pre.get() as usize)?;
122        let mut folded = self.folded.borrow_mut();
123        if folded.at != pre {
124            let repo = Repo::folding(log.get(..pre.get() as usize)?).ok()?;
125            *folded = Folded {
126                at: pre,
127                document: repo.document().clone(),
128            };
129        }
130        let mut index = DocIndex::default();
131        let timeline = synthesize(&index.view(&folded.document), commit);
132        if let Ok(after) = folded.document.try_apply(commit) {
133            *folded = Folded {
134                at: rev,
135                document: after,
136            };
137        }
138        Some(timeline)
139    }
140
141    /// The tools this tutorial teaches, in the order its log names them.
142    ///
143    /// A `teaches/<tool>` tag on the rev whose commit that tool made
144    /// (`docs/tutorial-levels.md`): the tutorial's own log says what it is
145    /// about, so there is no sidecar, no filename convention and nothing to
146    /// keep in step with the containers. A spelling no tool answers to is
147    /// skipped rather than refused — a tag is authored text.
148    pub fn teaches(&self) -> Vec<ToolName> {
149        let mut taught = Vec::new();
150        for (_, name) in self.store.tags().iter() {
151            let Some(tool) = name
152                .strip_prefix(TEACHES)
153                .and_then(names::from_command_name)
154            else {
155                continue;
156            };
157            if !taught.contains(&tool) {
158                taught.push(tool);
159            }
160        }
161        taught
162    }
163
164    /// The document the commit at `rev` is depicted against — what a player
165    /// draws the timeline over.
166    ///
167    /// Cloned rather than borrowed: the reader carries one fold forward and
168    /// a player asks for this once per step, where it asks for a
169    /// [`Frame`](crate::choreography::Frame) once per frame.
170    pub fn document(&self, rev: Rev) -> Option<Document> {
171        let pre = rev.prev()?;
172        let log = self.store.repo().log();
173        if pre.get() as usize > log.len() {
174            return None;
175        }
176        let mut folded = self.folded.borrow_mut();
177        if folded.at != pre {
178            let repo = Repo::folding(log.get(..pre.get() as usize)?).ok()?;
179            *folded = Folded {
180                at: pre,
181                document: repo.document().clone(),
182            };
183        }
184        Some(folded.document.clone())
185    }
186
187    /// What is said at `rev` — a note that has been taken back says nothing
188    /// (D17). A chapter's own rev answers with the chapter note: a note that
189    /// opens a section is still a note, and it is what a viewer arriving
190    /// there should be told.
191    pub fn narration(&self, rev: Rev) -> Option<&Note> {
192        self.store.notes().shown_at(rev)
193    }
194
195    pub fn store(&self) -> &Store {
196        &self.store
197    }
198
199    /// The chapter notes, each carrying the summed length of the commits
200    /// from its own rev up to the next chapter's.
201    fn sections(&self) -> Vec<Chapter> {
202        let opens: Vec<(Rev, String)> = self
203            .store
204            .notes()
205            .shown()
206            .filter(|(_, note)| note.show == NoteDisplay::Chapter)
207            .map(|(at, note)| (at, note.text.clone()))
208            .collect();
209        let end = self.store.repo().rev().next();
210        opens
211            .iter()
212            .enumerate()
213            .map(|(nth, (at, title))| Chapter {
214                at: *at,
215                title: title.clone(),
216                duration: self.spans(*at, opens.get(nth + 1).map_or(end, |(next, _)| *next)),
217            })
218            .collect()
219    }
220
221    /// How long the rev at `rev` holds on screen.
222    ///
223    /// Its depiction — or, where it depicts nothing and something is *said*
224    /// at it, long enough to read that. A note is an empty commit and
225    /// synthesizes a zero-length timeline (7·7), so without this a
226    /// walkthrough's narration would flash past in one frame and a
227    /// tool-pick chapter would never be seen at all.
228    ///
229    /// One resolver, two consumers: the player's clock reads it and so does
230    /// [`Chapter::duration`], so the length a library card advertises is the
231    /// length the player really spends (7·8's rule, kept while the UI half
232    /// gave the substrate something new to answer).
233    pub fn span(&self, rev: Rev) -> Duration {
234        let depicted = self
235            .timeline(rev)
236            .map_or(Duration::ZERO, |timeline| timeline.duration());
237        if depicted.is_zero() && self.narration(rev).is_some() {
238            DWELL
239        } else {
240            depicted
241        }
242    }
243
244    /// How long the commits in `from..until` take to play.
245    fn spans(&self, from: Rev, until: Rev) -> Duration {
246        revs(from, until).map(|rev| self.span(rev)).sum()
247    }
248}
249
250/// `from..until`, in steps of one. A [`Rev`] is minted by the fold and never
251/// by arithmetic, so a range over them is walked rather than constructed.
252fn revs(from: Rev, until: Rev) -> impl Iterator<Item = Rev> {
253    std::iter::successors(Some(from), |rev| Some(rev.next())).take_while(move |rev| *rev < until)
254}
255
256/// Every commit in `tutorial` plays: the C3 oracle over the timeline the
257/// reader hands out, against a fold this walk makes for itself rather than
258/// through the reader's own cache.
259#[cfg(test)]
260pub(crate) fn assert_plays(tutorial: &Tutorial) {
261    let log = tutorial.store().repo().log().to_vec();
262    assert_eq!(
263        log.len(),
264        tutorial.revs().count(),
265        "precondition: one rev per commit",
266    );
267    for (nth, (rev, commit)) in tutorial.revs().zip(&log).enumerate() {
268        let before = Repo::folding(&log[..nth])
269            .expect("the prefix folds")
270            .document()
271            .clone();
272        let timeline = tutorial
273            .timeline(rev)
274            .expect("the reader depicts every rev it holds");
275        assert_eq!(timeline.label(), commit.label());
276        if commit.ops().is_empty() {
277            assert!(
278                timeline.duration().is_zero() && timeline.recovered().is_none(),
279                "an empty commit depicts nothing at rev {}",
280                rev.get(),
281            );
282        } else {
283            crate::choreography::tests::assert_timeline_recovers(&before, commit, &timeline);
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    use std::path::PathBuf;
293
294    use crate::atomic::tests::TempDir;
295    use crate::store::container::{Access, Container, ReadOnlyReason};
296    use crate::store::handle::Clock;
297    use crate::store::record::{Identity, WallTime};
298    use crate::store::tests::fixture;
299
300    /// A container built through the real write door: two setup commits, then
301    /// two chapters, each opened by a chapter note, narrated, and worked in.
302    fn recorded(dir: &TempDir) -> PathBuf {
303        let root = dir.join("demo.bwx");
304        let author = Identity::new("ada");
305        let mut store = Store::create(
306            &root,
307            Clock::Pinned {
308                at: WallTime::EPOCH,
309                step: Duration::from_secs(1),
310            },
311        )
312        .expect("the container is laid out");
313        for commit in fixture::edits(2) {
314            store.submit_edit(commit, &author).expect("a setup commit");
315        }
316        store
317            .note("Draw a block", None, NoteDisplay::Chapter, &author)
318            .expect("the first chapter");
319        store
320            .note(
321                "Pick the tool, then click",
322                None,
323                NoteDisplay::Narration,
324                &author,
325            )
326            .expect("its narration");
327        store
328            .submit_edit(
329                fixture::commit("Added a block", vec![fixture::block_create(3, "c")]),
330                &author,
331            )
332            .expect("the chapter's edit");
333        store
334            .note("Move it", None, NoteDisplay::Chapter, &author)
335            .expect("the second chapter");
336        store
337            .submit_edit(
338                fixture::commit("Moved a block", vec![fixture::block_move(3, 12)]),
339                &author,
340            )
341            .expect("the chapter's edit");
342        store
343            .submit_edit(
344                fixture::commit("Renamed a block", vec![fixture::block_rename(3, "core")]),
345                &author,
346            )
347            .expect("the chapter's second edit");
348        root
349    }
350
351    #[test]
352    fn a_tutorial_reads_back_its_chapters() {
353        let dir = fixture::dir("tutorial-chapters");
354        let tutorial = Tutorial::open(&recorded(&dir)).expect("the tutorial opens");
355
356        assert_eq!(tutorial.name(), "demo");
357        assert_eq!(
358            tutorial
359                .chapters()
360                .iter()
361                .map(|chapter| (chapter.at.get(), chapter.title.clone()))
362                .collect::<Vec<_>>(),
363            [(3, "Draw a block".to_owned()), (6, "Move it".to_owned())],
364        );
365    }
366
367    /// The number the UI half is not allowed to invent: a chapter runs as
368    /// long as the revs inside it hold on screen — their depictions, plus
369    /// [`DWELL`] wherever something is *said* over a commit that depicts
370    /// nothing — and the commits outside every chapter (the setup) belong to
371    /// none of them.
372    #[test]
373    fn a_chapters_duration_is_the_sum_of_the_commits_it_holds() {
374        let dir = fixture::dir("tutorial-durations");
375        let tutorial = Tutorial::open(&recorded(&dir)).expect("the tutorial opens");
376        let played = |from: u64, until: u64| -> Duration {
377            tutorial
378                .revs()
379                .filter(|rev| rev.get() >= from && rev.get() < until)
380                .map(|rev| tutorial.span(rev))
381                .sum()
382        };
383        assert!(
384            !played(1, 9).is_zero(),
385            "precondition: this container depicts something",
386        );
387
388        let [first, second] = tutorial.chapters() else {
389            panic!("two chapters were recorded");
390        };
391        assert_eq!(first.duration, played(3, 6));
392        assert_eq!(second.duration, played(6, 9));
393        assert_eq!(
394            first.duration + second.duration,
395            played(3, 9),
396            "the chapters must partition everything after the first one begins",
397        );
398        assert!(
399            !played(1, 3).is_zero() && played(3, 9) < played(1, 9),
400            "the setup commits belong to no chapter",
401        );
402    }
403
404    /// A note's own commit is empty, so the two notes inside the first
405    /// chapter add nothing to its length.
406    #[test]
407    fn a_notes_commit_depicts_nothing() {
408        let dir = fixture::dir("tutorial-note-duration");
409        let tutorial = Tutorial::open(&recorded(&dir)).expect("the tutorial opens");
410        for rev in tutorial
411            .revs()
412            .filter(|rev| rev.get() == 3 || rev.get() == 4)
413        {
414            let timeline = tutorial.timeline(rev).expect("a timeline per rev");
415            assert!(timeline.duration().is_zero());
416            assert!(timeline.recovered().is_none());
417        }
418    }
419
420    #[test]
421    fn narration_is_read_at_the_rev_it_was_written_at() {
422        let dir = fixture::dir("tutorial-narration");
423        let tutorial = Tutorial::open(&recorded(&dir)).expect("the tutorial opens");
424        let said = |rev: u64| {
425            tutorial
426                .revs()
427                .find(|at| at.get() == rev)
428                .and_then(|at| tutorial.narration(at))
429                .map(|note| (note.text.clone(), note.show))
430        };
431        assert_eq!(
432            said(4),
433            Some((
434                "Pick the tool, then click".to_owned(),
435                NoteDisplay::Narration
436            )),
437        );
438        assert_eq!(
439            said(3),
440            Some(("Draw a block".to_owned(), NoteDisplay::Chapter)),
441            "a chapter note is what is said where the chapter opens",
442        );
443        assert_eq!(said(5), None, "an edit narrates nothing");
444    }
445
446    /// D17's undo rule reaching the reader: a note taken back is not said,
447    /// and the chapter it opened is not offered.
448    #[test]
449    fn a_chapter_taken_back_is_not_offered() {
450        let dir = fixture::dir("tutorial-undone-chapter");
451        let root = recorded(&dir);
452        let author = Identity::new("ada");
453        let mut store = Store::open(&root, Clock::System).expect("the container reopens");
454        let at = store
455            .note("Wrap up", None, NoteDisplay::Chapter, &author)
456            .expect("a third chapter");
457        assert_eq!(
458            Tutorial::open(&root)
459                .expect("the tutorial opens")
460                .chapters()
461                .len(),
462            3,
463            "precondition: the third chapter stands before it is taken back",
464        );
465        store.undo(at, &author).expect("the chapter is taken back");
466        drop(store);
467
468        let tutorial = Tutorial::open(&root).expect("the tutorial opens");
469        assert_eq!(tutorial.narration(at), None);
470        assert_eq!(
471            tutorial
472                .chapters()
473                .iter()
474                .map(|chapter| chapter.title.clone())
475                .collect::<Vec<_>>(),
476            ["Draw a block".to_owned(), "Move it".to_owned()],
477        );
478    }
479
480    /// The reader carries one fold forward, so a timeline must not depend on
481    /// the order the player asked for it in.
482    #[test]
483    fn a_timeline_is_the_same_whichever_order_it_is_asked_for() {
484        let dir = fixture::dir("tutorial-order");
485        let tutorial = Tutorial::open(&recorded(&dir)).expect("the tutorial opens");
486        let described = |rev| {
487            tutorial
488                .timeline(rev)
489                .expect("a timeline per rev")
490                .describe()
491        };
492        let forwards: Vec<String> = tutorial.revs().map(described).collect();
493        let mut order: Vec<Rev> = tutorial.revs().collect();
494        order.reverse();
495        let mut backwards: Vec<String> = order.into_iter().map(described).collect();
496        backwards.reverse();
497        assert!(
498            forwards.iter().any(|text| text.contains("morph")),
499            "precondition: this container depicts a move",
500        );
501        assert_eq!(forwards, backwards);
502    }
503
504    #[test]
505    fn every_commit_in_a_hand_built_tutorial_plays() {
506        let dir = fixture::dir("tutorial-plays");
507        assert_plays(&Tutorial::open(&recorded(&dir)).expect("the tutorial opens"));
508    }
509
510    /// The three tutorials the repo actually ships: each opens through the
511    /// read-only door, reports the chapters it was recorded with, and every
512    /// commit in it depicts a timeline that recovers the commit. The
513    /// containers are checked-in text, so this holds the reader to the demos
514    /// rather than to a container the test just built.
515    #[test]
516    fn every_shipped_tutorial_plays() {
517        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tutorials");
518        for id in ["first-block", "first-route", "resize-move"] {
519            let tutorial = Tutorial::open(&dir.join(format!("{id}.bwx")))
520                .unwrap_or_else(|e| panic!("{id} does not open: {e:?}"));
521            assert_eq!(tutorial.name(), id);
522            assert!(
523                !tutorial.chapters().is_empty(),
524                "{id} was recorded with no chapter to navigate by",
525            );
526            assert_plays(&tutorial);
527        }
528    }
529
530    /// What each shipped tutorial teaches, and the rev whose commit the tool
531    /// made — the tag goes there because a tag names a rev, and the rev that
532    /// used the tool is the one that has anything to say about it.
533    const TAUGHT: [(&str, u64, ToolName); 3] = [
534        ("first-block", 6, ToolName::NewBlock),
535        ("first-route", 6, ToolName::Route),
536        ("resize-move", 6, ToolName::Select),
537    ];
538
539    fn shipped() -> PathBuf {
540        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/tutorials")
541    }
542
543    /// The user's format ruling: *"Include any meta information about the
544    /// tutorial in the log of the tutorial."* So what a tutorial teaches is
545    /// a `teaches/<tool>` tag in its own log, and this test is both the
546    /// check and — under `BLOCKWORX_TAG_TUTORIALS=1` — the way the shipped
547    /// containers were given theirs, on the deleted converter's own
548    /// precedent (`docs/tutorial-levels.md`).
549    #[test]
550    fn every_shipped_tutorial_says_what_it_teaches() {
551        if std::env::var_os("BLOCKWORX_TAG_TUTORIALS").is_some() {
552            publish_the_teaching_tags();
553        }
554        for (id, _, tool) in TAUGHT {
555            let tutorial =
556                Tutorial::open(&shipped().join(format!("{id}.bwx"))).expect("the tutorial opens");
557            assert_eq!(
558                tutorial.teaches(),
559                vec![tool],
560                "{id} does not say what it teaches",
561            );
562        }
563    }
564
565    /// Append the tags the table names, through the ordinary write door. A
566    /// tag folds nothing and spends no rev, so this leaves `document.json`
567    /// and every existing line of `log.jsonl` exactly as they were.
568    ///
569    /// Idempotent, so re-running it is not a way to grow the log: a
570    /// container that already says what it teaches is left alone.
571    fn publish_the_teaching_tags() {
572        let author = Identity::new("tutorial converter");
573        for (id, at, tool) in TAUGHT {
574            let root = shipped().join(format!("{id}.bwx"));
575            let name = format!("{TEACHES}{}", tool.command_name().expect("a named tool"));
576            let at = blockworx_doc::fixtures::rev(at);
577            if Tutorial::open(&root)
578                .expect("the tutorial opens")
579                .store()
580                .tags()
581                .get(at)
582                == Some(name.as_str())
583            {
584                continue;
585            }
586            let mut store = Store::open(
587                &root,
588                // Past the last record the converter wrote, and pinned, so a
589                // re-run of this produces the same bytes.
590                Clock::Pinned {
591                    at: WallTime::from_unix_millis(9_000),
592                    step: Duration::from_secs(1),
593                },
594            )
595            .expect("the container opens for writing");
596            store.tag(at, &name, &author).expect("the tag is appended");
597        }
598    }
599
600    /// The lock discipline a reader owes the user: opening a tutorial takes
601    /// nothing, so the container stays writable — and a container someone
602    /// else is writing still opens to be read.
603    #[test]
604    fn opening_a_tutorial_leaves_the_lock_alone() {
605        let dir = fixture::dir("tutorial-lock");
606        let root = recorded(&dir);
607        let tutorial = Tutorial::open(&root).expect("the tutorial opens");
608        assert!(matches!(
609            tutorial.store().access(),
610            Access::ReadOnly(ReadOnlyReason::Reading),
611        ));
612        assert!(
613            matches!(
614                Container::open(&root, WallTime::EPOCH)
615                    .expect("a writer opens")
616                    .access(),
617                Access::Writable(_),
618            ),
619            "the reader took the lock",
620        );
621
622        let held = Store::create(&dir.join("held.bwx"), Clock::System).expect("a live session");
623        assert!(
624            Tutorial::open(held.root()).is_ok(),
625            "a locked container must still be readable",
626        );
627    }
628}