variables: data-merge model and engine commands
This commit is contained in:
parent
e3874e89e7
commit
555b0e60d3
|
|
@ -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<SavedSelection>,
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<String, DataValue>,
|
||||
}
|
||||
|
||||
/// 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<Variable>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub datasets: Vec<DataSet>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub bindings: BTreeMap<NodeId, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_dataset: Option<String>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@ fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
p.get(k).and_then(Value::as_u64).map(|v| v as usize)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<Vec<String>> {
|
||||
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<DataValue> {
|
||||
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<std::collections::BTreeMap<String, DataValue>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let st = s.doc()?;
|
||||
let variables: Vec<Value> = 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<Value> {
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let st = s.doc()?;
|
||||
let datasets: Vec<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
let name = var_name(p, "dataset.select")?;
|
||||
apply_dataset(s, &name)
|
||||
}
|
||||
|
||||
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue