526 lines
24 KiB
Rust
526 lines
24 KiB
Rust
//! Learn: interactive tutorials on the Home screen (our own content, licensed with the repo).
|
||
//!
|
||
//! A tutorial is a list of steps. Each step names the UI element to highlight (an automation id,
|
||
//! see `docs/control-protocol.md`), the commands that complete it when the user performs them,
|
||
//! and the commands "Show me" runs to perform it for the user. Progress lives on the
|
||
//! [`Session`] (not the project), so opening the demo project in a step keeps the tutorial going.
|
||
//!
|
||
//! Commands: `learn.list`, `learn.start {id}`, `learn.step {action: next|back|showMe|goto,
|
||
//! index?}`, `learn.stop`, `learn.state`.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::commands::{CommandSpec, always, bad, str_p};
|
||
use crate::{Result, Session, cmd, query};
|
||
|
||
/// One step of a tutorial.
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct Step {
|
||
pub title: String,
|
||
pub text: String,
|
||
/// Automation id of the element to highlight. `*` matches any run of characters and
|
||
/// `{layer}` stands for the first selected layer's id (see [`resolve_target`]).
|
||
pub target: Option<String>,
|
||
/// Commands that complete the step when they run (empty: a reading step, advanced with Next).
|
||
pub accepts: Vec<String>,
|
||
/// Parameters the completing command must carry (a subset match; `null` = any).
|
||
pub when: Value,
|
||
/// What "Show me" runs: (command, params), in order.
|
||
pub show: Vec<(String, Value)>,
|
||
}
|
||
|
||
/// An interactive tutorial.
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct Tutorial {
|
||
pub id: String,
|
||
pub title: String,
|
||
pub summary: String,
|
||
/// Rough length in minutes.
|
||
pub minutes: u32,
|
||
pub steps: Vec<Step>,
|
||
}
|
||
|
||
/// Where the user is in a tutorial.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct Progress {
|
||
pub tutorial: String,
|
||
/// Index of the current step (`== steps.len()` once finished).
|
||
pub step: usize,
|
||
pub done: bool,
|
||
}
|
||
|
||
struct S<'a> {
|
||
title: &'a str,
|
||
text: &'a str,
|
||
target: Option<&'a str>,
|
||
accepts: &'a [&'a str],
|
||
when: Value,
|
||
show: Vec<(&'a str, Value)>,
|
||
}
|
||
|
||
fn step(s: S) -> Step {
|
||
Step {
|
||
title: s.title.into(),
|
||
text: s.text.into(),
|
||
target: s.target.map(str::to_string),
|
||
accepts: s.accepts.iter().map(|a| a.to_string()).collect(),
|
||
when: s.when,
|
||
show: s.show.into_iter().map(|(c, p)| (c.to_string(), p)).collect(),
|
||
}
|
||
}
|
||
|
||
/// The built-in tutorials.
|
||
pub fn tutorials() -> Vec<Tutorial> {
|
||
vec![
|
||
Tutorial {
|
||
id: "animate-title".into(),
|
||
title: "Animate a title".into(),
|
||
summary: "Make a composition, type a title and fly it in with two position keyframes and an easy ease.".into(),
|
||
minutes: 4,
|
||
steps: vec![
|
||
step(S {
|
||
title: "Make a composition",
|
||
text: "Everything you animate lives in a composition. Choose Composition ▸ New Composition (Cmd+N) and keep the HD 1920×1080 settings.",
|
||
target: Some("menu.Composition"),
|
||
accepts: &["comp.new"],
|
||
when: Value::Null,
|
||
show: vec![("comp.new", json!({"name": "Title Animation", "width": 1920, "height": 1080, "duration": 5}))],
|
||
}),
|
||
step(S {
|
||
title: "Type a title",
|
||
text: "Choose Layer ▸ New ▸ Text (or the Type tool) and type a short title. A new text layer appears at the top of the Timeline.",
|
||
target: Some("menu.Layer"),
|
||
accepts: &["layer.newText"],
|
||
when: Value::Null,
|
||
show: vec![("layer.newText", json!({"text": "Hello, motion", "size": 120}))],
|
||
}),
|
||
step(S {
|
||
title: "Start the animation",
|
||
text: "Twirl the text layer open, then Transform. Click the stopwatch beside Position: it turns blue and records a keyframe at the current time.",
|
||
target: Some("timeline.layer.{layer}.twirl"),
|
||
accepts: &["prop.toggleAnimation", "prop.addKey", "prop.toggleKey", "anim.addKeyframe"],
|
||
when: Value::Null,
|
||
show: vec![
|
||
("prop.set", json!({"path": "transform/position", "value": [-400.0, 540.0, 0.0]})),
|
||
("prop.toggleAnimation", json!({"path": "transform/position", "value": true})),
|
||
],
|
||
}),
|
||
step(S {
|
||
title: "Move in time",
|
||
text: "Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler above the layer bars.",
|
||
target: Some("timeline.ruler"),
|
||
accepts: &["time.set", "time.step", "time.go"],
|
||
when: Value::Null,
|
||
show: vec![("time.set", json!({"time": 1.0}))],
|
||
}),
|
||
step(S {
|
||
title: "Set the end position",
|
||
text: "Drag the title to the middle of the frame in the Composition viewer, or scrub the Position value. Because the stopwatch is on, a second keyframe appears by itself.",
|
||
target: Some("viewer.comp"),
|
||
accepts: &["prop.set"],
|
||
when: json!({"path": "transform/position"}),
|
||
show: vec![("prop.set", json!({"path": "transform/position", "value": [960.0, 540.0, 0.0]}))],
|
||
}),
|
||
step(S {
|
||
title: "Smooth it out",
|
||
text: "Select both keyframes (drag a box around them, or click the Position name) and press F9 for Easy Ease. The title now glides in and settles.",
|
||
target: Some("timeline.ruler"),
|
||
accepts: &["keys.easyEase"],
|
||
when: Value::Null,
|
||
show: vec![("keys.selectAll", json!({})), ("keys.easyEase", json!({}))],
|
||
}),
|
||
step(S {
|
||
title: "Watch it",
|
||
text: "Press the Spacebar (or Play in the Preview panel) to play your animation. Press it again to stop. That's it: you made your first animation.",
|
||
target: Some("panel.Preview"),
|
||
accepts: &["playback.toggle"],
|
||
when: Value::Null,
|
||
show: vec![("time.set", json!({"time": 0.0})), ("playback.toggle", json!({}))],
|
||
}),
|
||
],
|
||
},
|
||
Tutorial {
|
||
id: "track-attach".into(),
|
||
title: "Track and attach".into(),
|
||
summary: "Follow a moving element with the point tracker, then hang a null on the track and parent a layer to it.".into(),
|
||
minutes: 5,
|
||
steps: vec![
|
||
step(S {
|
||
title: "Open the demo",
|
||
text: "We'll track the moving lower third in the demo project. Open it from Home ▸ Open Demo Project (or File ▸ Open Demo Project).",
|
||
target: Some("menu.File"),
|
||
accepts: &["file.openDemoProject"],
|
||
when: Value::Null,
|
||
show: vec![("file.openDemoProject", json!({}))],
|
||
}),
|
||
step(S {
|
||
title: "Pick what to track",
|
||
text: "Click the Lower Third layer in the Timeline, then press I to jump to where it starts (0:00:05:00). The tracker follows the pixels of the selected layer's source.",
|
||
target: Some("timeline.layer.*.row"),
|
||
accepts: &["layer.select"],
|
||
when: Value::Null,
|
||
show: vec![("layer.select", json!({"layers": ["Lower Third"]})), ("time.set", json!({"time": 5.5}))],
|
||
}),
|
||
step(S {
|
||
title: "Add a track point",
|
||
text: "Open Window ▸ Tracker and click Track Motion. A track point appears: the inner box is the feature to follow, the outer box is where to look for it on the next frame.",
|
||
target: Some("tracker.trackMotion"),
|
||
accepts: &["track.motion", "track.new"],
|
||
when: Value::Null,
|
||
show: vec![("track.motion", json!({}))],
|
||
}),
|
||
step(S {
|
||
title: "Analyze",
|
||
text: "Click Analyze forward ▶ in the Tracker panel. The point follows the feature frame by frame and leaves a path of keyframes behind.",
|
||
target: Some("tracker.analyze.forward"),
|
||
accepts: &["track.analyze"],
|
||
when: Value::Null,
|
||
show: vec![("track.analyze", json!({"direction": "forward", "end": 6.0, "wait": true}))],
|
||
}),
|
||
step(S {
|
||
title: "Make a null to carry the motion",
|
||
text: "Choose Layer ▸ New ▸ Null Object. A null is invisible; it only holds a transform, which makes it a handy anchor for tracked motion.",
|
||
target: Some("menu.Layer"),
|
||
accepts: &["layer.newNull"],
|
||
when: Value::Null,
|
||
show: vec![("layer.newNull", json!({"name": "Track Null"}))],
|
||
}),
|
||
step(S {
|
||
title: "Point the track at the null",
|
||
text: "Select Lower Third again, click Edit Target… in the Tracker panel and choose the null as the motion target.",
|
||
target: Some("tracker.editTarget"),
|
||
accepts: &["track.setTarget"],
|
||
when: Value::Null,
|
||
show: vec![("track.setTarget", json!({"layer": "Lower Third", "target": "Track Null"}))],
|
||
}),
|
||
step(S {
|
||
title: "Apply",
|
||
text: "Click Apply and keep X and Y. The null's Position now follows the track.",
|
||
target: Some("tracker.apply"),
|
||
accepts: &["track.apply"],
|
||
when: Value::Null,
|
||
show: vec![("track.apply", json!({"layer": "Lower Third", "dimensions": "xy"}))],
|
||
}),
|
||
step(S {
|
||
title: "Attach a layer",
|
||
text: "Drag a layer's pick whip (the spiral in Parent & Link) onto the null, or pick it from the menu. The child now rides along with the track.",
|
||
target: Some("timeline.layer.*.pickWhip"),
|
||
accepts: &["layer.setParent"],
|
||
when: Value::Null,
|
||
show: vec![("layer.setParent", json!({"layers": ["Tagline"], "parent": "Track Null"}))],
|
||
}),
|
||
],
|
||
},
|
||
Tutorial {
|
||
id: "3d-scene".into(),
|
||
title: "Make a 3D scene".into(),
|
||
summary: "Turn flat layers into 3D, light them, add a camera and look at the scene from outside the frame.".into(),
|
||
minutes: 5,
|
||
steps: vec![
|
||
step(S {
|
||
title: "Make a composition",
|
||
text: "Choose Composition ▸ New Composition. Any size works; 1920×1080 is a good start.",
|
||
target: Some("menu.Composition"),
|
||
accepts: &["comp.new"],
|
||
when: Value::Null,
|
||
show: vec![("comp.new", json!({"name": "3D Scene", "width": 1920, "height": 1080, "duration": 5}))],
|
||
}),
|
||
step(S {
|
||
title: "Add a floor",
|
||
text: "Choose Layer ▸ New ▸ Solid and make a large solid in a calm colour. It will become the floor.",
|
||
target: Some("menu.Layer"),
|
||
accepts: &["layer.newSolid"],
|
||
when: Value::Null,
|
||
show: vec![("layer.newSolid", json!({"name": "Floor", "color": "#3c5a82", "width": 2400, "height": 2400}))],
|
||
}),
|
||
step(S {
|
||
title: "Make it 3D",
|
||
text: "Click the cube switch of the solid in the Timeline. The layer gains a Z position and X/Y/Z rotation.",
|
||
target: Some("timeline.layer.{layer}.switch.threeD"),
|
||
accepts: &["layer.setSwitch"],
|
||
when: json!({"switch": "threeD"}),
|
||
show: vec![("layer.setSwitch", json!({"switch": "threeD", "value": true}))],
|
||
}),
|
||
step(S {
|
||
title: "Lay it down",
|
||
text: "Set X Rotation to -80° (twirl open Transform and scrub the value) so the solid lies like a floor, and push its Position down a little.",
|
||
target: Some("timeline.layer.{layer}.twirl"),
|
||
accepts: &["prop.set"],
|
||
when: Value::Null,
|
||
show: vec![
|
||
("prop.set", json!({"path": "transform/rotationX", "value": -80.0})),
|
||
("prop.set", json!({"path": "transform/position", "value": [960.0, 760.0, 0.0]})),
|
||
],
|
||
}),
|
||
step(S {
|
||
title: "Light it",
|
||
text: "Choose Layer ▸ New ▸ Light and pick Spot. 3D layers now catch light and fall-off.",
|
||
target: Some("menu.Layer"),
|
||
accepts: &["layer.newLight"],
|
||
when: Value::Null,
|
||
show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))],
|
||
}),
|
||
step(S {
|
||
title: "Add a camera",
|
||
text: "Choose Layer ▸ New ▸ Camera. The comp is now seen through this camera; orbit it with the camera tools (press C).",
|
||
target: Some("menu.Layer"),
|
||
accepts: &["layer.newCamera"],
|
||
when: Value::Null,
|
||
show: vec![("layer.newCamera", json!({"name": "Camera"}))],
|
||
}),
|
||
step(S {
|
||
title: "Step outside the camera",
|
||
text: "In the 3D View popup under the viewer choose Custom View 1. You see the scene, the camera and the light from a working view.",
|
||
target: Some("viewer.view3d"),
|
||
accepts: &[
|
||
"view.set3DView",
|
||
"view.3d.custom1",
|
||
"view.3d.custom2",
|
||
"view.3d.custom3",
|
||
"view.3d.front",
|
||
"view.3d.top",
|
||
"view.3d.left",
|
||
"view.3d.right",
|
||
"view.3d.back",
|
||
"view.3d.bottom",
|
||
],
|
||
when: Value::Null,
|
||
show: vec![("view.3d.custom1", json!({}))],
|
||
}),
|
||
step(S {
|
||
title: "See past the frame",
|
||
text: "Click Extended Viewer beside the 3D View popup. The viewer now shows the layers beyond the comp's edges, with the frame outlined.",
|
||
target: Some("viewer.extendedViewer"),
|
||
accepts: &["view.extendedViewer"],
|
||
when: Value::Null,
|
||
show: vec![("view.extendedViewer", json!({"value": true}))],
|
||
}),
|
||
],
|
||
},
|
||
]
|
||
}
|
||
|
||
/// The tutorial with this id.
|
||
pub fn find(id: &str) -> Option<Tutorial> {
|
||
tutorials().into_iter().find(|t| t.id == id)
|
||
}
|
||
|
||
/// The automation id pattern of a step's target with `{layer}` filled in (`*` stays a wildcard).
|
||
pub fn resolve_target(target: &str, layer: Option<u64>) -> String {
|
||
match layer {
|
||
Some(l) => target.replace("{layer}", &l.to_string()),
|
||
None => target.replace("{layer}", "*"),
|
||
}
|
||
}
|
||
|
||
/// Whether an automation id matches a target pattern (`*` = any run of characters).
|
||
pub fn target_matches(pattern: &str, id: &str) -> bool {
|
||
let parts: Vec<&str> = pattern.split('*').collect();
|
||
if parts.len() == 1 {
|
||
return pattern == id;
|
||
}
|
||
let mut rest = id;
|
||
for (i, part) in parts.iter().enumerate() {
|
||
if i == 0 {
|
||
let Some(r) = rest.strip_prefix(part) else { return false };
|
||
rest = r;
|
||
} else if i == parts.len() - 1 {
|
||
return rest.ends_with(part);
|
||
} else {
|
||
let Some(at) = rest.find(part) else { return false };
|
||
rest = &rest[at + part.len()..];
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// `want`'s keys all equal in `got` (`null` matches anything).
|
||
fn params_match(want: &Value, got: &Value) -> bool {
|
||
match want {
|
||
Value::Object(m) => m.iter().all(|(k, v)| got.get(k) == Some(v)),
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
impl Session {
|
||
/// The current tutorial and step, if one is running.
|
||
pub fn learn_current(&self) -> Option<(Tutorial, usize)> {
|
||
let p = self.learn.as_ref()?;
|
||
Some((find(&p.tutorial)?, p.step))
|
||
}
|
||
|
||
/// A command ran: complete the current step when it is one the step accepts.
|
||
pub(crate) fn learn_observe(&mut self, id: &str, params: &Value) {
|
||
if id.starts_with("learn.") {
|
||
return;
|
||
}
|
||
let Some((t, i)) = self.learn_current() else { return };
|
||
let Some(st) = t.steps.get(i) else { return };
|
||
if st.accepts.iter().any(|a| a == id) && params_match(&st.when, params) {
|
||
self.learn_goto(&t, i + 1);
|
||
}
|
||
}
|
||
|
||
fn learn_goto(&mut self, t: &Tutorial, i: usize) {
|
||
let n = t.steps.len();
|
||
let i = i.min(n);
|
||
if let Some(p) = &mut self.learn {
|
||
p.step = i;
|
||
p.done = i >= n;
|
||
}
|
||
if i >= n {
|
||
self.toast(format!("Tutorial complete: {}", t.title));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn state_json(s: &Session) -> Value {
|
||
match s.learn_current() {
|
||
Some((t, i)) => json!({
|
||
"tutorial": t.id,
|
||
"title": t.title,
|
||
"step": i,
|
||
"steps": t.steps.len(),
|
||
"done": i >= t.steps.len(),
|
||
"current": t.steps.get(i).map(|st| json!({
|
||
"title": st.title,
|
||
"text": st.text,
|
||
"target": st.target.as_deref().map(|x| resolve_target(x, s.state.selected_layers.first().map(|l| l.0))),
|
||
"accepts": st.accepts,
|
||
})),
|
||
}),
|
||
None => Value::Null,
|
||
}
|
||
}
|
||
|
||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||
Ok(json!(
|
||
tutorials()
|
||
.iter()
|
||
.map(|t| json!({"id": t.id, "title": t.title, "summary": t.summary, "minutes": t.minutes, "steps": t.steps.iter().map(|s| s.title.clone()).collect::<Vec<_>>()}))
|
||
.collect::<Vec<_>>()
|
||
))
|
||
}
|
||
|
||
fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = str_p(p, "id").or_else(|| str_p(p, "tutorial")).ok_or_else(|| bad("learn.start", "missing `id`"))?;
|
||
let t = find(id).ok_or_else(|| bad("learn.start", format!("unknown tutorial `{id}` (see learn.list)")))?;
|
||
s.learn = Some(Progress { tutorial: t.id.clone(), step: 0, done: false });
|
||
Ok(state_json(s))
|
||
}
|
||
|
||
fn step_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (t, i) = s.learn_current().ok_or_else(|| bad("learn.step", "no tutorial is running (learn.start)"))?;
|
||
match str_p(p, "action").unwrap_or("next") {
|
||
"next" => s.learn_goto(&t, i + 1),
|
||
"back" | "prev" | "previous" => s.learn_goto(&t, i.saturating_sub(1)),
|
||
"goto" => {
|
||
let n = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("learn.step", "goto needs `index`"))?;
|
||
s.learn_goto(&t, n as usize);
|
||
}
|
||
"showMe" | "show" => {
|
||
let st = t.steps.get(i).ok_or_else(|| bad("learn.step", "the tutorial is finished"))?;
|
||
for (c, params) in &st.show {
|
||
s.execute(c, params.clone()).map_err(|e| bad("learn.step", format!("Show me: {c}: {e}")))?;
|
||
}
|
||
// Steps whose commands don't complete them (or reading steps) still move on.
|
||
if s.learn.as_ref().is_some_and(|p| p.step == i) {
|
||
s.learn_goto(&t, i + 1);
|
||
}
|
||
}
|
||
a => return Err(bad("learn.step", format!("action must be next|back|showMe|goto, not `{a}`"))),
|
||
}
|
||
Ok(state_json(s))
|
||
}
|
||
|
||
fn stop(s: &mut Session, _: &Value) -> Result<Value> {
|
||
s.learn = None;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
query!("learn.list", "Learn Tutorials", "{} → [{id, title, summary, minutes, steps}]", list),
|
||
query!("learn.state", "Tutorial State", "{} → {tutorial, step, steps, done, current: {title, text, target, accepts}} | null", |s, _| Ok(state_json(
|
||
s
|
||
))),
|
||
cmd!("learn.start", "Start Tutorial", [], None, "{id}", always, start),
|
||
cmd!("learn.step", "Tutorial Step", [], None, "{action?: next|back|showMe|goto, index?}", always, step_cmd),
|
||
cmd!("learn.stop", "Close Tutorial", [], None, "{}", always, stop),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn targets_match_with_wildcards() {
|
||
assert!(target_matches("timeline.layer.*.row", "timeline.layer.12.row"));
|
||
assert!(!target_matches("timeline.layer.*.row", "timeline.layer.12.bar"));
|
||
assert!(target_matches("menu.File", "menu.File"));
|
||
assert!(!target_matches("menu.File", "menu.File.2"));
|
||
assert_eq!(resolve_target("timeline.layer.{layer}.twirl", Some(7)), "timeline.layer.7.twirl");
|
||
assert_eq!(resolve_target("timeline.layer.{layer}.twirl", None), "timeline.layer.*.twirl");
|
||
}
|
||
|
||
#[test]
|
||
fn every_step_names_real_commands() {
|
||
for t in tutorials() {
|
||
assert!(!t.steps.is_empty(), "{}", t.id);
|
||
for st in &t.steps {
|
||
for c in st.accepts.iter().chain(st.show.iter().map(|(c, _)| c)) {
|
||
assert!(crate::commands::find(c).is_some(), "{}: unknown command {c}", t.id);
|
||
}
|
||
assert!(!st.show.is_empty(), "{}: `{}` has no Show me", t.id, st.title);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tutorials_run_end_to_end_with_show_me() {
|
||
for id in ["animate-title", "3d-scene", "track-attach"] {
|
||
let mut s = Session::default();
|
||
s.execute("learn.start", json!({"id": id})).unwrap();
|
||
let n = find(id).unwrap().steps.len();
|
||
for i in 0..n {
|
||
let st = s.execute("learn.step", json!({"action": "showMe"})).unwrap_or_else(|e| panic!("{id} step {i}: {e}"));
|
||
assert_eq!(st["step"], json!(i + 1), "{id}: {st}");
|
||
}
|
||
assert_eq!(s.execute("learn.state", json!({})).unwrap()["done"], json!(true));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn performing_the_command_advances() {
|
||
let mut s = Session::default();
|
||
let list = s.execute("learn.list", json!({})).unwrap();
|
||
assert_eq!(list.as_array().unwrap().len(), 3);
|
||
s.execute("learn.start", json!({"id": "animate-title"})).unwrap();
|
||
// Unrelated commands don't advance.
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
assert_eq!(s.learn.as_ref().unwrap().step, 0);
|
||
s.execute("comp.new", json!({"name": "Mine"})).unwrap();
|
||
assert_eq!(s.learn.as_ref().unwrap().step, 1);
|
||
s.execute("layer.newText", json!({"text": "Hi"})).unwrap();
|
||
assert_eq!(s.learn.as_ref().unwrap().step, 2);
|
||
let cur = s.execute("learn.state", json!({})).unwrap();
|
||
let lid = s.state.selected_layers[0].0;
|
||
assert_eq!(cur["current"]["target"], json!(format!("timeline.layer.{lid}.twirl")));
|
||
// Back and Next.
|
||
s.execute("learn.step", json!({"action": "back"})).unwrap();
|
||
assert_eq!(s.learn.as_ref().unwrap().step, 1);
|
||
s.execute("learn.step", json!({})).unwrap();
|
||
assert_eq!(s.learn.as_ref().unwrap().step, 2);
|
||
// A matching command with the wrong parameters doesn't complete a `when` step.
|
||
s.execute("learn.start", json!({"id": "3d-scene"})).unwrap();
|
||
s.execute("learn.step", json!({"action": "goto", "index": 2})).unwrap();
|
||
s.execute("layer.setSwitch", json!({"switch": "shy", "value": true})).ok();
|
||
assert_eq!(s.learn.as_ref().unwrap().step, 2);
|
||
s.execute("learn.stop", json!({})).unwrap();
|
||
assert!(s.learn.is_none());
|
||
assert!(s.execute("learn.step", json!({})).is_err());
|
||
}
|
||
}
|