Feature history on the left side, with contiguous folders (#449)
The feature history can sit in a panel on the left, with folders that hold one unbroken run of features, drag and drop as one undo step, and a resizable divider. Timeline folders save under format 17.
This commit is contained in:
1 parent
be55a49b04
commit
a8b3b21fd0
17 files changed
+2336
-37
No files matched your search
@@ -211,6 +211,28 @@ mod tests {
|
||||
assert!(t.execute("timeline.paste", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Source"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
|
||||
let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap();
|
||||
let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap();
|
||||
let first = s.doc.find_feature("First").unwrap().id;
|
||||
let second = s.doc.find_feature("Second").unwrap().id;
|
||||
assert_eq!(folder.features, vec![first, pasted, second]);
|
||||
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_fillet_rounds_the_copied_body_it_names() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -25,11 +25,28 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("Delete")
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
|
||||
.doc_only()
|
||||
.params("group: id | groups: [ids] (features remain in the timeline)"),
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
|
||||
.doc_only()
|
||||
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
|
||||
@@ -187,6 +204,302 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
}
|
||||
|
||||
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
timeline_group_id_field(s, p, "group", cmd)
|
||||
}
|
||||
|
||||
fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result<u64> {
|
||||
let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?;
|
||||
if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) }
|
||||
}
|
||||
|
||||
fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
|
||||
let values = match p.get("features") {
|
||||
Some(Value::Array(values)) => values,
|
||||
None if !required => return Ok(Vec::new()),
|
||||
_ => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
|
||||
};
|
||||
if values.is_empty() && required {
|
||||
return Err(bad(cmd, "`features` must list at least one feature"));
|
||||
}
|
||||
if values.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many features"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = feature_id(s, Some(value), cmd)?;
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
|
||||
let values = match p.get("groups") {
|
||||
Some(Value::Array(values)) => values,
|
||||
None if !required => return Ok(Vec::new()),
|
||||
None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
_ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
};
|
||||
if values.is_empty() && required {
|
||||
return Err(bad(cmd, "`groups` must list at least one folder"));
|
||||
}
|
||||
if values.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many folders"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?;
|
||||
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
|
||||
return Err(bad(cmd, format!("no timeline folder {id}")));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool {
|
||||
let sources: std::collections::BTreeSet<u64> = sources.iter().copied().collect();
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
let mut current = Some(target);
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = current {
|
||||
if sources.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
steps += 1;
|
||||
if steps > s.doc.timeline_groups.len() {
|
||||
return true;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet<u64> {
|
||||
let by_id: std::collections::BTreeMap<u64, &solidcraft_doc::TimelineGroup> = doc.timeline_groups.iter().map(|group| (group.id, group)).collect();
|
||||
let mut children = std::collections::BTreeMap::<u64, Vec<u64>>::new();
|
||||
for group in &doc.timeline_groups {
|
||||
if let Some(parent) = group.parent {
|
||||
children.entry(parent).or_default().push(group.id);
|
||||
}
|
||||
}
|
||||
let mut pending = roots.to_vec();
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
let mut features = std::collections::BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !visited.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(group) = by_id.get(&id) {
|
||||
features.extend(group.features.iter().copied());
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
for group in &mut doc.timeline_groups {
|
||||
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
|
||||
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
|
||||
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
|
||||
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.group";
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
|
||||
Some(name) if name.len() <= 128 => name.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
|
||||
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
|
||||
};
|
||||
let id = s
|
||||
.doc
|
||||
.timeline_groups
|
||||
.iter()
|
||||
.map(|group| group.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
|
||||
};
|
||||
if let Some(parent) = parent
|
||||
&& group_move_would_cycle(s, parent, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
|
||||
}
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = Some(id);
|
||||
}
|
||||
}
|
||||
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.ungroup";
|
||||
let ids = match p.get("groups") {
|
||||
Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => {
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?;
|
||||
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
|
||||
return Err(bad(cmd, format!("no timeline folder {id}")));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")),
|
||||
Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
None => vec![timeline_group_id(s, p, cmd)?],
|
||||
};
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> =
|
||||
s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect();
|
||||
let surviving_parent = |mut parent: Option<u64>| {
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = parent.filter(|id| ids.contains(id)) {
|
||||
parent = parents.get(&id).copied().flatten();
|
||||
steps += 1;
|
||||
if steps > parents.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
parent
|
||||
};
|
||||
let mut next = (*s.doc).clone();
|
||||
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
|
||||
.timeline_groups
|
||||
.iter()
|
||||
.filter(|group| ids.contains(&group.id))
|
||||
.map(|group| (surviving_parent(group.parent), group.features.clone()))
|
||||
.collect();
|
||||
for group in &mut next.timeline_groups {
|
||||
if ids.contains(&group.id) {
|
||||
continue;
|
||||
}
|
||||
group.parent = surviving_parent(group.parent);
|
||||
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
}
|
||||
next.timeline_groups.retain(|group| !ids.contains(&group.id));
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"groups": ids}))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.rename_group";
|
||||
let id = timeline_group_id(s, p, cmd)?;
|
||||
let name = str_(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 128)
|
||||
.ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
|
||||
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
|
||||
group.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"group": id, "name": name}))
|
||||
}
|
||||
|
||||
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move_to_group";
|
||||
let features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
if features.is_empty() && groups.is_empty() {
|
||||
return Err(bad(cmd, "list at least one feature or folder"));
|
||||
}
|
||||
let target = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(target) = target
|
||||
&& group_move_would_cycle(s, target, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if Some(group.id) == target {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
}
|
||||
for group in &mut next.timeline_groups {
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = target;
|
||||
}
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"features": features, "groups": groups, "group": target}))
|
||||
}
|
||||
|
||||
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move_many";
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
if features.is_empty() && groups.is_empty() {
|
||||
return Err(bad(cmd, "list at least one feature or folder"));
|
||||
}
|
||||
let target = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(target) = target
|
||||
&& group_move_would_cycle(s, target, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
|
||||
}
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let moving: Vec<u64> =
|
||||
s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if Some(group.id) == target {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = target;
|
||||
}
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let pos = match p.get("feature") {
|
||||
@@ -423,6 +736,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = next.features.remove(from);
|
||||
let name = f.name.clone();
|
||||
next.features.insert(to, f);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
// Everything that evaluated before must still evaluate.
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
@@ -435,6 +750,60 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.reorder_many";
|
||||
let ids = group_features(s, p, true, cmd)?;
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())}))
|
||||
}
|
||||
|
||||
fn reorder_features(
|
||||
s: &Session,
|
||||
mut next: solidcraft_doc::Document,
|
||||
ids: &[u64],
|
||||
slot: usize,
|
||||
cmd: &str,
|
||||
) -> Result<(solidcraft_doc::Document, Vec<u64>)> {
|
||||
let n = next.features.len();
|
||||
let slot = slot.min(n);
|
||||
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
let ordered: Vec<u64> = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count();
|
||||
let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len()));
|
||||
|
||||
let mut moving = Vec::new();
|
||||
next.features.retain(|feature| {
|
||||
if selected.contains(&feature.id) {
|
||||
moving.push(feature.clone());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at]
|
||||
.iter()
|
||||
.chain(moving.iter())
|
||||
.chain(next.features[insert_at..].iter())
|
||||
.map(|feature| feature.id));
|
||||
next.features.splice(insert_at..insert_at, moving);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
if !unchanged {
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() {
|
||||
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
|
||||
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
|
||||
}
|
||||
}
|
||||
Ok((next, ordered))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
|
||||
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
|
||||
if let FeatureKind::Fillet { style, .. } = kind {
|
||||
|
||||
@@ -310,3 +310,163 @@ fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions(
|
||||
assert_eq!(length["value"], 3.0);
|
||||
assert_eq!(width["value"], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
|
||||
let base = s.doc.find_feature("Base").unwrap().id;
|
||||
let round = s.doc.find_feature("Round").unwrap().id;
|
||||
|
||||
let made = run(&mut s, "timeline.group", json!({"name": "Housing", "features": ["Base", round]}));
|
||||
let group = made["group"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
|
||||
run(&mut s, "timeline.rename", json!({"feature": base, "name": "Foundation"}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round], "folders follow stable ids, not names");
|
||||
run(&mut s, "timeline.rename_group", json!({"group": group, "name": "Main housing"}));
|
||||
run(&mut s, "timeline.move_to_group", json!({"features": [round], "group": null}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base]);
|
||||
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
|
||||
run(&mut s, "timeline.delete", json!({"features": [base]}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![round], "deleted features leave folders cleanly");
|
||||
run(&mut s, "timeline.ungroup", json!({"group": group}));
|
||||
assert!(s.doc.timeline_groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let names = |session: &Session| session.doc.features.iter().map(|feature| feature.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
|
||||
|
||||
run(&mut s, "timeline.reorder_many", json!({"features": ["First", "Third"], "position": 3}));
|
||||
assert_eq!(names(&s), vec!["Second", "First", "Third"], "selected features keep their relative order");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(names(&s), vec!["Second", "First", "Third"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
|
||||
let first = run(&mut s, "timeline.group", json!({"name": "One", "features": ["First"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "creating a folder is undoable");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups.first().map(|group| group.id), Some(first));
|
||||
let second = run(&mut s, "timeline.group", json!({"name": "Two", "features": ["Second"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.ungroup", json!({"groups": [first, second]}));
|
||||
assert!(s.doc.timeline_groups.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups.len(), 2, "removing several folders is one undoable command");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "removing folders is redoable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.group", &json!({"features": ["First", "Third"]})).unwrap_err().to_string();
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.reorder_many", &json!({"features": ["First"], "position": 3})).unwrap_err().to_string();
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
assert_eq!(s.doc, before, "a refused reorder must not split the folder");
|
||||
|
||||
for params in [json!({"features": [999_u64], "group": group}), json!({"groups": [999_u64], "group": group})] {
|
||||
let error = s.execute("timeline.move_to_group", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("no feature") || error.contains("no timeline folder"), "{error}");
|
||||
}
|
||||
|
||||
let mut damaged: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let first = s.doc.find_feature("First").unwrap().id;
|
||||
let third = s.doc.find_feature("Third").unwrap().id;
|
||||
damaged["timeline_groups"] = json!([{"id": 1, "name": "Broken", "features": [first, third]}]);
|
||||
let error = solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("not contiguous"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_move_many_is_atomic_and_one_undo_step() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First"]}))["group"].as_u64().unwrap();
|
||||
let before = (*s.doc).clone();
|
||||
run(&mut s, "timeline.move_many", json!({"features": ["Third"], "group": group, "position": 1}));
|
||||
assert_eq!(s.doc.features.iter().map(|feature| feature.name.as_str()).collect::<Vec<_>>(), vec!["First", "Third", "Second"]);
|
||||
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before, "one undo restores both order and folder membership");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
|
||||
|
||||
let child = run(&mut s, "timeline.group", json!({"name": "Child", "features": ["Second"]}))["group"].as_u64().unwrap();
|
||||
let before_parent = (*s.doc).clone();
|
||||
let parent = run(&mut s, "timeline.group", json!({"name": "Parent", "groups": [group, child]}))["group"].as_u64().unwrap();
|
||||
assert!(s.doc.timeline_groups.iter().filter(|folder| folder.parent == Some(parent)).count() == 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before_parent, "creating a parent for selected folders is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_timeline_move_many_keeps_folder_membership_and_order() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Destination"}))["group"].as_u64().unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let error = s.execute("timeline.move_many", &json!({"features": ["Base"], "group": group, "position": 2})).unwrap_err().to_string();
|
||||
assert!(error.contains("breaks"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo, "a refused atomic move adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_can_nest_and_reject_cycles() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
let root = run(&mut s, "timeline.group", json!({"name": "Root"}))["group"].as_u64().unwrap();
|
||||
let child = run(&mut s, "timeline.group", json!({"name": "Child", "parent": root}))["group"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, Some(root));
|
||||
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": null}));
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None);
|
||||
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": root}));
|
||||
let error = s.execute("timeline.move_to_group", &json!({"groups": [root], "group": child})).unwrap_err().to_string();
|
||||
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.move_many", &json!({"groups": [root], "group": child, "position": 0})).unwrap_err().to_string();
|
||||
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
|
||||
run(&mut s, "timeline.ungroup", json!({"group": root}));
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children");
|
||||
}
|
||||
Reference in new issue
Block a user