1use 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
37pub const TEACHES: &str = "teaches/";
40
41pub const DWELL: Duration = Duration::from_millis(1_400);
45
46#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct Chapter {
50 pub at: Rev,
51 pub title: String,
52 pub duration: Duration,
53}
54
55pub struct Tutorial {
57 name: String,
58 chapters: Vec<Chapter>,
59 store: Store,
60 folded: RefCell<Folded>,
65}
66
67struct Folded {
68 at: Rev,
69 document: Document,
70}
71
72impl Tutorial {
73 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 pub fn name(&self) -> &str {
103 &self.name
104 }
105
106 pub fn chapters(&self) -> &[Chapter] {
107 &self.chapters
108 }
109
110 pub fn revs(&self) -> impl Iterator<Item = Rev> {
112 revs(Rev::ZERO.next(), self.store.repo().rev().next())
113 }
114
115 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 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 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 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 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 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 fn spans(&self, from: Rev, until: Rev) -> Duration {
246 revs(from, until).map(|rev| self.span(rev)).sum()
247 }
248}
249
250fn 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#[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[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}