diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b45b921a..3c555def 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -41,6 +41,7 @@ pub mod style_libs; pub mod swatches; pub mod text; pub mod trace; +pub mod variables; use std::collections::BTreeMap; use std::sync::Arc; @@ -91,6 +92,7 @@ pub use text::{ TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; +pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; @@ -561,6 +563,9 @@ pub struct Document { /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub saved_selections: Vec, + /// Variables (data merge): named values bound to objects, applied per dataset. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub variables: Variables, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] @@ -693,6 +698,7 @@ impl Document { guides: vec![], views: vec![], saved_selections: vec![], + variables: Variables::default(), grid: GridPrefs::default(), raster_effects_ppi: 72.0, images: BTreeMap::new(), diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs new file mode 100644 index 00000000..14a7402d --- /dev/null +++ b/crates/doc/src/variables.rs @@ -0,0 +1,101 @@ +//! Variables (data merge): named values bound to objects, with datasets that apply +//! them all at once. One template document produces many variants (badges, price +//! lists, localized copies) by selecting a dataset instead of editing each object. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::NodeId; + +/// What a variable drives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VariableKind { + /// A type object's characters. + #[default] + Text, + /// Whether bound objects show. + Visibility, +} + +impl VariableKind { + /// Parse the `kind` of `variable.define` (`text` or `visibility`). + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "text" => Some(Self::Text), + "visibility" | "visible" => Some(Self::Visibility), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Text => "text", + Self::Visibility => "visibility", + } + } +} + +/// One named variable. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Variable { + pub name: String, + pub kind: VariableKind, +} + +/// One value a dataset gives a variable. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DataValue { + Text(String), + Visible(bool), +} + +impl DataValue { + /// Whether this value fits the variable's kind (a dataset may carry values for + /// variables of either kind; each binding takes what fits it). + pub fn fits(&self, kind: VariableKind) -> bool { + matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility)) + } +} + +/// One named row of values, applied with `dataset.select`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DataSet { + pub name: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub values: BTreeMap, +} + +/// The variable state of a document: definitions, datasets, bindings +/// (object id → variable name) and which dataset is active. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Variables { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variables: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub datasets: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bindings: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_dataset: Option, +} + +impl Variables { + pub fn variable(&self, name: &str) -> Option<&Variable> { + self.variables.iter().find(|v| v.name == name) + } + + /// Drop the bindings (and the active dataset) that name what is gone. + pub fn prune(&mut self, names: &[String]) { + self.variables.retain(|v| !names.contains(&v.name)); + for dataset in &mut self.datasets { + dataset.values.retain(|k, _| !names.contains(k)); + } + self.bindings.retain(|_, v| !names.contains(v)); + if self.active_dataset.as_ref().is_some_and(|a| names.contains(a)) { + self.active_dataset = None; + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dceb266e..228410de 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -77,6 +77,7 @@ pub(crate) mod textwrap; pub(crate) mod threads; pub(crate) mod typecmd; mod typemenu; +mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; @@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(maskedit::specs()); v.extend(tabs::specs()); v.extend(docmenu::specs()); + v.extend(variablecmds::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 509ea200..5787dd54 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -112,6 +112,19 @@ fn text_ref(s: &Session, id: NodeId) -> Result { } } +/// Replace a type object's whole content: the new text takes the replaced text's +/// style and paragraph styles follow, exactly as `text.editRange` over the full +/// range does. Dataset apply and friends share this so Variables edits behave +/// like Type tool edits. +pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> { + let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; + let len = edit::runs_len(&t.runs); + t.splice_paras(0, len, text); + edit::replace_range(&mut t.runs, 0, len, text); + refresh_bounds(t); + Ok(()) +} + fn byte_param(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_u64).map(|v| v as usize) } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs new file mode 100644 index 00000000..eecc2133 --- /dev/null +++ b/crates/engine/src/cmd/variablecmds.rs @@ -0,0 +1,275 @@ +//! Variables (data merge): named values bound to objects, applied per dataset. +//! +//! One template document produces many variants: bind a type object to a `text` +//! variable and anything to a `visibility` variable, put a row of values in each +//! dataset, and `dataset.select` swaps the whole document to that row in one undo +//! step. Agents reach every command through `run_command`, like all engine commands. + +use serde_json::{Value, json}; +use vectorcraft_doc::{DataValue, NodeId, NodeKind, VariableKind}; + +use super::edit::selected_roots; +use super::textedit::set_plain_text; +use super::*; + +fn var_name(p: &Value, cmd: &str) -> Result { + match str_param(p, "name").filter(|v| !v.is_empty()) { + Some(v) => Ok(v.to_string()), + None => Err(bad(cmd, "missing `name`")), + } +} + +fn names_param(p: &Value, cmd: &str) -> Result> { + match p.get("names").and_then(Value::as_array) { + Some(names) if !names.is_empty() => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + match n.as_str().filter(|v| !v.is_empty()) { + Some(v) => out.push(v.to_string()), + None => return Err(bad(cmd, "`names` must be non-empty strings")), + } + } + Ok(out) + } + _ => Err(bad(cmd, "missing `names`")), + } +} + +fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result { + match kind { + VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())), + VariableKind::Visibility => v.as_bool().map(DataValue::Visible), + } + .ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind"))) +} + +fn values_param(s: &Session, p: &Value, cmd: &str) -> Result> { + let mut out = std::collections::BTreeMap::new(); + let Some(given) = p.get("values") else { return Ok(out) }; + let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) }; + for (var, v) in obj { + let kind = s.doc()?.doc.variables.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?; + out.insert(var.clone(), dataset_value(cmd, var, kind, v)?); + } + Ok(out) +} + +fn define(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.define"; + let name = var_name(p, C)?; + let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?; + if s.doc()?.doc.variables.variable(&name).is_some() { + return Err(bad(C, format!("variable `{name}` is already defined"))); + } + s.edit("New Variable", |d, _| { + d.variables.variables.push(vectorcraft_doc::Variable { name: name.clone(), kind }); + Ok(()) + })?; + Ok(json!({"name": name, "kind": kind.label()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.delete"; + let names = names_param(p, C)?; + let n = s.edit("Delete Variables", |d, _| { + d.variables.prune(&names); + Ok(names.len()) + })?; + Ok(json!({"deleted": n})) +} + +fn list(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let variables: Vec = st + .doc + .variables + .variables + .iter() + .map(|v| { + let bindings = st.doc.variables.bindings.values().filter(|b| *b == &v.name).count(); + json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings}) + }) + .collect(); + Ok(json!({"variables": variables})) +} + +fn bind(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.bind"; + let name = str_param(p, "variable").filter(|v| !v.is_empty()).map(str::to_string).ok_or_else(|| bad(C, "missing `variable`"))?; + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + if ids.is_empty() { + return Err(bad(C, "nothing to bind")); + } + let kind = s.doc()?.doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; + // A text variable only drives type; visibility drives anything that exists. + if kind == VariableKind::Text { + for id in &ids { + match s.doc()?.doc.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => {} + Some(_) => return Err(bad(C, format!("node {} is not type", id.0))), + None => return Err(EngineError::NoNode(*id)), + } + } + } else { + for id in &ids { + if s.doc()?.doc.node(*id).is_none() { + return Err(EngineError::NoNode(*id)); + } + } + } + s.edit("Bind Variable", |d, _| { + for id in &ids { + d.variables.bindings.insert(*id, name.clone()); + } + Ok(()) + })?; + Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>()})) +} + +fn unbind(s: &mut Session, p: &Value) -> Result { + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let n = s.edit("Unbind Variable", |d, _| { + let mut n = 0; + for id in &ids { + if d.variables.bindings.remove(id).is_some() { + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"unbound": n})) +} + +fn dataset_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.new"; + let name = var_name(p, C)?; + let values = values_param(s, p, C)?; + if s.doc()?.doc.variables.datasets.iter().any(|d| d.name == name) { + return Err(bad(C, format!("dataset `{name}` already exists"))); + } + s.edit("New Data Set", |d, _| { + d.variables.datasets.push(vectorcraft_doc::DataSet { name: name.clone(), values: values.clone() }); + Ok(()) + })?; + Ok(json!({"name": name})) +} + +fn dataset_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.delete"; + let names = names_param(p, C)?; + let n = s.edit("Delete Data Sets", |d, _| { + let before = d.variables.datasets.len(); + d.variables.datasets.retain(|d| !names.contains(&d.name)); + if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) { + d.variables.active_dataset = None; + } + Ok(before - d.variables.datasets.len()) + })?; + Ok(json!({"deleted": n})) +} + +fn dataset_list(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let datasets: Vec = st.doc.variables.datasets.iter().map(|d| json!({"name": d.name, "values": d.values})).collect(); + Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset})) +} + +fn apply_dataset(s: &mut Session, name: &str) -> Result { + const C: &str = "dataset.select"; + let (applied, skipped) = s.edit("Data Set", |d, _| { + let ds = d.variables.datasets.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + let bindings: Vec<(NodeId, String)> = d.variables.bindings.iter().map(|(id, v)| (*id, v.clone())).collect(); + let mut applied = 0usize; + let mut skipped = 0usize; + for (id, var) in bindings { + let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) { + (Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()), + _ => { + skipped += 1; + continue; + } + }; + let done = match (kind, &value) { + (VariableKind::Text, DataValue::Text(text)) => { + if d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { + set_plain_text(d, id, text).is_ok() + } else { + false + } + } + (VariableKind::Visibility, DataValue::Visible(show)) => { + if let Some(n) = d.node_mut(id) { + n.visible = *show; + true + } else { + false + } + } + _ => false, + }; + if done { + applied += 1; + } else { + skipped += 1; + } + } + d.variables.active_dataset = Some(name.to_string()); + Ok((applied, skipped)) + })?; + Ok(json!({"name": name, "applied": applied, "skipped": skipped})) +} + +fn dataset_select(s: &mut Session, p: &Value) -> Result { + let name = var_name(p, "dataset.select")?; + apply_dataset(s, &name) +} + +fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { + let st = s.doc()?; + if st.doc.variables.datasets.is_empty() { + return Err(bad(cmd, "no datasets")); + } + let at = st.doc.variables.active_dataset.as_ref().and_then(|a| st.doc.variables.datasets.iter().position(|d| &d.name == a)); + let next = match at { + Some(i) if forward => (i + 1) % st.doc.variables.datasets.len(), + Some(i) => (i + st.doc.variables.datasets.len() - 1) % st.doc.variables.datasets.len(), + None if forward => 0, + None => st.doc.variables.datasets.len() - 1, + }; + let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?; + apply_dataset(s, &name) +} + +pub fn specs() -> Vec { + vec![ + cmd!("variable.define", "New Variable", [], None, "{name, kind: text|visibility} define a variable", has_doc, define), + cmd!("variable.delete", "Delete Variable", [], None, "{names} delete variables with their bindings and values", has_doc, delete), + cmd!("variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), + cmd!( + "variable.bind", + "Bind Variable", + [], + None, + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type", + has_doc, + bind + ), + cmd!("variable.unbind", "Unbind Variable", [], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind), + cmd!("dataset.new", "New Data Set", [], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), + cmd!("dataset.delete", "Delete Data Set", [], None, "{names} delete datasets", has_doc, dataset_delete), + cmd!("dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list), + cmd!("dataset.select", "Select Data Set", [], None, "{name} apply a dataset in one undo step → {applied, skipped}", has_doc, dataset_select), + cmd!("dataset.next", "Next Data Set", [], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(s, "dataset.next", true)), + cmd!("dataset.prev", "Previous Data Set", [], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| dataset_step( + s, + "dataset.prev", + false + )), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a964610..b8774641 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1663,6 +1663,8 @@ mod tests_typearea; #[cfg(test)] mod tests_units; #[cfg(test)] +mod tests_variables; +#[cfg(test)] mod tests_webexport; #[cfg(test)] mod tests_widthpoints; diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs new file mode 100644 index 00000000..6f03fd11 --- /dev/null +++ b/crates/engine/src/tests_variables.rs @@ -0,0 +1,137 @@ +//! Variables (data merge): definitions with kinds, bindings to objects, datasets +//! applied in one undo step, and validation that rejects instead of reinterpreting. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, content: &str) -> NodeId { + let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": content})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn plain(s: &Session, id: NodeId) -> String { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +#[test] +fn text_and_visibility_datasets_apply_in_one_undo_step() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0))); + assert_eq!(plain(&s, t), "Bob"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + + // One undo step covers the whole dataset application. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Alice"); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Bob"); +} + +#[test] +fn dataset_next_and_prev_wrap_around() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + for name in ["A", "B"] { + s.execute("dataset.new", &json!({"name": name, "values": {"Name": name}})).unwrap(); + } + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "A"); + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "B"); + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "A"); + s.execute("dataset.prev", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "B"); + let v = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(v["active"], "B"); +} + +#[test] +fn mismatched_and_missing_bindings_are_skipped_and_counted() { + let mut s = session(); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + // A text variable bound to a rectangle applies to nothing; a dataset value for an + // unbound variable is ignored. + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Ghost": "z"}})).unwrap_err(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [r.0]})).unwrap_err(); + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0))); + assert_eq!(plain(&s, t), "y"); +} + +#[test] +fn deleting_a_variable_cleans_bindings_values_and_active_dataset() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0)); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({}))); + assert_eq!(sets["active"], "D"); + s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(0)); + assert!(sets["active"].is_null()); + let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap(); + assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({}))); +} + +#[test] +fn bad_variables_requests_are_errors_not_panics() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "N", "kind": "text"})).unwrap(); + for (cmd, params) in [ + ("variable.define", json!({})), + ("variable.define", json!({"name": "", "kind": "text"})), + ("variable.define", json!({"name": "N", "kind": "colour"})), + ("variable.define", json!({"name": "N", "kind": "text"})), + ("variable.delete", json!({})), + ("variable.bind", json!({})), + ("variable.bind", json!({"variable": "N"})), + ("variable.bind", json!({"variable": "Missing", "ids": [1]})), + ("dataset.new", json!({})), + ("dataset.new", json!({"name": "D", "values": []})), + ("dataset.new", json!({"name": "D", "values": {"N": 5}})), + ("dataset.select", json!({"name": "Missing"})), + ("dataset.next", json!({})), + ] { + assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params}"); + } +}