//! `keys.delete` reports the keys it removed, not the keys it was asked to remove. use effectcraft_project::LayerId; use serde_json::json; use crate::{KeyRef, Session}; fn setup() -> (Session, u64, u64) { let mut s = Session::default(); s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); for t in 0..3 { s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap(); } let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid; (s, layer, prop) } fn keys_of(s: &Session, layer: u64, prop: u64) -> Vec { let keys = &s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys; keys.iter().map(|k| KeyRef { layer: LayerId(layer), prop, time: k.time }).collect() } #[test] fn delete_keys_counts_only_the_keys_it_removed() { let (mut s, layer, prop) = setup(); let keys = keys_of(&s, layer, prop); assert_eq!(keys.len(), 3); // Two real keys, one of them listed twice, and a key of a property that does not exist. let missing = KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[1].time }; s.state.selected_keys = vec![keys[0], keys[2], keys[0], missing]; let removed = s.execute_checked("keys.delete", json!({})).unwrap(); assert_eq!(removed, json!(2)); assert_eq!(keys_of(&s, layer, prop), vec![keys[1]]); } #[test] fn delete_keys_reports_zero_when_no_selected_key_exists() { let (mut s, layer, prop) = setup(); let keys = keys_of(&s, layer, prop); s.state.selected_keys = vec![KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[0].time }]; let removed = s.execute_checked("keys.delete", json!({})).unwrap(); assert_eq!(removed, json!(0)); assert_eq!(keys_of(&s, layer, prop), keys); } /// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone. #[test] fn a_locked_layers_keys_cannot_be_deleted_or_edited() { let (mut s, layer, prop) = setup(); let keys = keys_of(&s, layer, prop); s.state.selected_keys = keys.clone(); s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); let removed = s.execute_checked("keys.delete", json!({})).unwrap(); assert_eq!(removed, json!(0)); assert_eq!(keys_of(&s, layer, prop), keys); assert_eq!(s.state.selected_keys, keys, "the selection stays"); let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string(); assert!(e.contains("locked"), "{e}"); let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64(); assert_eq!(value, 0.0); // Unlocked again, both work. s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap(); assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3)); } /// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them /// selected; unlocked, they change them. #[test] fn selection_based_key_edits_skip_a_locked_layer() { let (mut s, layer, prop) = setup(); let keys = keys_of(&s, layer, prop); let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone(); let before = stored(&s); s.state.selected_keys = keys.clone(); s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] { s.execute_checked(command, json!({})).unwrap(); assert_eq!(stored(&s), before, "{command} changed a locked layer's keys"); assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys"); } s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); s.execute_checked("keys.easyEase", json!({})).unwrap(); assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys"); } #[test] fn spatial_handles_cannot_edit_a_locked_layer() { let (mut s, layer, _) = setup(); for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] { s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap(); } let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid; s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap(); s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); let before = s.project.to_json(); let selected = s.state.selected_keys.clone(); let undo_len = s.history.undo.len(); let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]}); let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string(); assert!(error.contains("locked"), "{error}"); assert_eq!(s.project.to_json(), before); assert_eq!(s.state.selected_keys, selected); assert_eq!(s.history.undo.len(), undo_len); s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); let unlocked = s.project.to_json(); s.execute("keys.setSpatialTangents", params).unwrap(); let edited = s.project.to_json(); assert_ne!(edited, unlocked); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.to_json(), unlocked); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(s.project.to_json(), edited); } #[test] fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() { for path_clip in [false, true] { for explicit in [false, true] { let (mut s, layer, opacity) = setup(); let target_path = if path_clip { "transform/position" } else { "transform/opacity" }; let source = if path_clip { s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap(); s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap(); s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid } else { opacity }; s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap(); s.execute("keys.copy", json!({})).unwrap(); s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap(); s.execute("time.set", json!({"time": 3})).unwrap(); s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap(); let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) }; let before = s.project.to_json(); let selected = s.state.selected_keys.clone(); let undo_len = s.history.undo.len(); let result = s.execute("keys.paste", params.clone()); assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}"); assert_eq!(s.project.to_json(), before); assert_eq!(s.state.selected_keys, selected); assert_eq!(s.history.undo.len(), undo_len); s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap(); let unlocked = s.project.to_json(); s.execute("keys.paste", params).unwrap(); let pasted = s.project.to_json(); assert_ne!(pasted, unlocked); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.to_json(), unlocked); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(s.project.to_json(), pasted); } } } #[test] fn key_paste_skips_locked_targets_without_changing_layer_pairing() { let (mut s, locked, locked_prop) = setup(); let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); for (time, value) in [(0, 100), (1, 80), (2, 60)] { s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap(); } let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid; let locked_keys = keys_of(&s, locked, locked_prop); s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect(); s.execute("keys.copy", json!({})).unwrap(); s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap(); s.execute("time.set", json!({"time": 3})).unwrap(); let before = s.project.to_json(); assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted"); assert_eq!(keys_of(&s, locked, locked_prop), locked_keys); assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k))); assert_eq!(s.state.selected_keys.len(), 6); let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys; assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::>(), [100.0, 80.0, 60.0]); let pasted = s.project.to_json(); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.to_json(), before); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(s.project.to_json(), pasted); } #[test] fn motion_path_paste_preserves_locked_source_selection() { for explicit in [false, true] { let (mut s, source, _) = setup(); s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap(); s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap(); let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid; s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap(); s.execute("keys.copy", json!({})).unwrap(); let locked_keys = s.state.selected_keys.clone(); let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap(); s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap(); s.state.selected_keys = locked_keys.clone(); let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) }; let before = s.project.to_json(); assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3)); assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k))); assert_eq!(s.state.selected_keys.len(), 4); assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3); let pasted = s.project.to_json(); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.to_json(), before); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(s.project.to_json(), pasted); } } #[test] fn key_transform_preserves_locked_keys_in_a_mixed_selection() { let (mut s, locked, locked_prop) = setup(); let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); for t in 0..3 { s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap(); } let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid; let locked_keys = keys_of(&s, locked, locked_prop); s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect(); s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap(); let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone(); let before = stored(&s, locked, locked_prop); let undo = s.history.undo.len(); for offset in [0.5, 1.0] { s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap(); assert_eq!(stored(&s, locked, locked_prop), before); assert_eq!(s.state.selected_keys.len(), 6); assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times"); assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys"); let moved = stored(&s, unlocked, unlocked_prop); for (i, key) in moved.iter().enumerate() { assert_eq!(key.time.seconds(), i as f64 + offset); assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0); } } assert_eq!(s.history.undo.len(), undo + 1, "one merged drag"); let after = stored(&s, unlocked, unlocked_prop); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(stored(&s, locked, locked_prop), before); assert_eq!(stored(&s, unlocked, unlocked_prop), before); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(stored(&s, locked, locked_prop), before); assert_eq!(stored(&s, unlocked, unlocked_prop), after); s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap(); s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap(); assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again"); }