effectcraft/crates/engine/tests/key_time_reply_contract.rs

202 lines
11 KiB
Rust

//! Omitted time bases retain existing result shapes even for valid keys outside [in, out).
use effectcraft_engine::Session;
use effectcraft_project::LayerId;
use effectcraft_time::Tick;
use serde_json::{Value, json};
const LAYER_TIME: f64 = 15.3;
const COMP_TIME: f64 = 25.8;
fn setup(text: bool) -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Reply contract", "width": 80, "height": 60, "duration": 20, "frameRate": 30})).unwrap();
let created = if text {
s.execute("layer.newText", json!({"name": "Document", "text": "Before"})).unwrap()
} else {
s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()
};
let layer = created["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layer": layer, "start": 10.5, "in": 10.5, "out": 20})).unwrap();
(s, layer)
}
fn project(s: &Session) -> Value {
serde_json::to_value(s.project.as_ref()).unwrap()
}
fn read(s: &mut Session, layer: u64, path: &str, time: f64) -> Value {
s.execute("prop.get", json!({"layer": layer, "path": path, "time": time})).unwrap()
}
fn outside_key(s: &Session, layer: u64, path: &str, layer_time: f64, comp_time: f64) {
let comp = s.active_comp().unwrap();
let owner = comp.layer(LayerId(layer)).unwrap();
let property = owner.props.prop(path).unwrap();
let at = Tick::from_seconds_f64(layer_time);
assert!(property.keys.iter().any(|key| key.time == at), "actual stored layer-time key");
assert_eq!(owner.comp_time(at), Tick::from_seconds_f64(comp_time), "actual comp-time mapping");
assert!(!owner.is_active_at(owner.comp_time(at)), "authored key is outside the active layer range");
}
fn undo_redo(s: &mut Session, before: &Value, after: &Value, history_before: usize) {
assert_ne!(before, after, "the public edit changed the project");
assert_eq!(s.history.undo.len(), history_before + 1, "one public edit or batch Undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(project(s), *before, "complete project Undo");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(project(s), *after, "complete project Redo");
}
fn seconds(actual: &Value, expected: f64) {
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-8, "{actual} != {expected}");
}
#[test]
fn legacy_outside_add_key_keeps_its_numeric_count_and_undo() {
let (mut s, layer) = setup(false);
let positive = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 20})).unwrap();
assert_eq!(positive.as_u64(), Some(1), "ordinary in-range numeric reply positive");
let before = project(&s);
let history_before = s.history.undo.len();
let reply = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap();
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
let after = project(&s);
undo_redo(&mut s, &before, &after, history_before);
eprintln!("LEGACY_REPLY addKey outside: {reply}");
assert_eq!(reply.as_u64(), Some(2), "omitted timeBase must preserve the numeric addKey count outside the active range");
}
#[test]
fn legacy_outside_scalar_set_keeps_its_value_and_undo() {
let (mut s, layer) = setup(false);
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 20})).unwrap();
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap();
let before = project(&s);
let history_before = s.history.undo.len();
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 40})).unwrap();
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
seconds(&read(&mut s, layer, "transform/opacity", COMP_TIME)["value"], 40.0);
let after = project(&s);
undo_redo(&mut s, &before, &after, history_before);
eprintln!("LEGACY_REPLY scalar set outside: {reply}");
assert_eq!(reply.as_f64(), Some(40.0), "omitted timeBase must preserve the scalar set value outside the active range");
}
#[test]
fn legacy_outside_selection_keeps_its_numeric_count_without_an_edit() {
let (mut s, layer) = setup(false);
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap();
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
let before = project(&s);
let history_before = s.history.undo.len();
let reply = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": LAYER_TIME}]})).unwrap();
assert_eq!(s.state.selected_keys.len(), 1, "actual owner selected");
assert_eq!(s.state.selected_keys[0].layer, LayerId(layer));
assert_eq!(s.state.selected_keys[0].time, Tick::from_seconds_f64(LAYER_TIME));
assert_eq!(project(&s), before);
assert_eq!(s.history.undo.len(), history_before);
eprintln!("LEGACY_REPLY select outside: {reply}");
assert_eq!(reply.as_u64(), Some(1), "omitted timeBase must preserve the numeric selection count outside the active range");
}
#[test]
fn legacy_outside_position_set_keeps_its_array_value_and_undo() {
let (mut s, layer) = setup(false);
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": LAYER_TIME, "value": [10, 20, 0]})).unwrap();
let before = project(&s);
let history_before = s.history.undo.len();
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/position", "time": LAYER_TIME, "value": [12, 34, 0]})).unwrap();
outside_key(&s, layer, "transform/position", LAYER_TIME, COMP_TIME);
let actual = read(&mut s, layer, "transform/position", COMP_TIME)["value"].clone();
let values = actual.as_array().unwrap();
assert_eq!(values.len(), 3, "ordinary Position value remains a vector");
seconds(&values[0], 12.0);
seconds(&values[1], 34.0);
seconds(&values[2], 0.0);
let after = project(&s);
undo_redo(&mut s, &before, &after, history_before);
eprintln!("LEGACY_REPLY array set outside: {reply}");
assert_eq!(reply, actual, "omitted timeBase must return the raw array value rather than a timing wrapper");
}
#[test]
fn legacy_outside_text_set_keeps_its_string_value_and_undo() {
let (mut s, layer) = setup(true);
let source = read(&mut s, layer, "text/sourceText", 10.5)["value"].clone();
assert_eq!(source.as_str(), Some("Before"), "public Source Text returns its text string");
s.execute("prop.addKey", json!({"layer": layer, "path": "text/sourceText", "time": LAYER_TIME, "value": source})).unwrap();
let before = project(&s);
let history_before = s.history.undo.len();
let reply = s.execute("prop.set", json!({"layer": layer, "path": "text/sourceText", "time": LAYER_TIME, "value": "Outside final"})).unwrap();
outside_key(&s, layer, "text/sourceText", LAYER_TIME, COMP_TIME);
let actual = read(&mut s, layer, "text/sourceText", COMP_TIME)["value"].clone();
assert_eq!(actual.as_str(), Some("Outside final"), "actual Source Text edit");
let after = project(&s);
undo_redo(&mut s, &before, &after, history_before);
eprintln!("LEGACY_REPLY text set outside: {reply}");
assert_eq!(reply, actual, "omitted timeBase must return the raw Source Text string unchanged");
}
#[test]
fn legacy_outside_count_remains_usable_by_the_documented_batch_reference() {
let (mut s, layer) = setup(false);
let before = project(&s);
let history_before = s.history.undo.len();
let outcome = s.execute(
"engine.batch",
json!({"label": "Use key count", "steps": [
{"command": "prop.addKey", "params": {"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60}},
{"command": "prop.set", "params": {"layer": layer, "path": "transform/rotation", "value": "$1"}}
]}),
);
eprintln!("LEGACY_REPLY batch count substitution: {outcome:?}");
assert!(outcome.is_ok(), "a valid outside addKey count must remain usable as the documented $1 numeric value: {outcome:?}");
let reply = outcome.unwrap();
assert_eq!(reply["steps"].as_u64(), Some(2));
assert_eq!(reply["results"][0].as_u64(), Some(1));
assert_eq!(reply["results"][1].as_f64(), Some(1.0));
seconds(&read(&mut s, layer, "transform/rotation", 10.5)["value"], 1.0);
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
let after = project(&s);
undo_redo(&mut s, &before, &after, history_before);
}
#[test]
fn explicit_time_bases_keep_timing_and_existing_object_warnings() {
let (mut s, layer) = setup(false);
let outside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "timeBase": "layer", "value": 60})).unwrap();
assert_eq!(outside["count"].as_u64(), Some(1));
seconds(&outside["layerTime"], LAYER_TIME);
seconds(&outside["compTime"], COMP_TIME);
assert!(outside["warning"].as_str().unwrap().contains("outside"));
let inside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "timeBase": "comp", "value": 20})).unwrap();
seconds(&inside["layerTime"], 4.8);
seconds(&inside["compTime"], 15.3);
assert_eq!(inside["count"].as_u64(), Some(2));
assert!(inside.get("warning").is_none());
let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": COMP_TIME}], "timeBase": "comp"})).unwrap();
assert_eq!(selected["count"].as_u64(), Some(1));
seconds(&selected["keys"][0]["compTime"], COMP_TIME);
assert!(selected["warning"].as_str().unwrap().contains("outside"));
let changed = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": COMP_TIME, "timeBase": "comp", "value": 40})).unwrap();
seconds(&changed["value"], 40.0);
seconds(&changed["compTime"], COMP_TIME);
assert!(changed["warning"].as_str().unwrap().contains("outside"));
let info = read(&mut s, layer, "transform/opacity", COMP_TIME);
let keys = info["keys"].as_array().unwrap();
let key = keys.iter().find(|key| (key["time"].as_f64().unwrap() - LAYER_TIME).abs() < 1e-8).unwrap();
seconds(&key["layerTime"], LAYER_TIME);
seconds(&key["compTime"], COMP_TIME);
assert!(key["warning"].as_str().unwrap().contains("outside"), "prop.get already has an object response");
let before = project(&s);
let history_before = s.history.undo.len();
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "newTime": 15.4})).unwrap();
assert!(moved.is_object(), "keys.set already returned an object without timeBase");
seconds(&moved["layerTime"], 15.4);
seconds(&moved["compTime"], 25.9);
assert!(moved["warning"].as_str().unwrap().contains("outside"));
outside_key(&s, layer, "transform/opacity", 15.4, 25.9);
let after = project(&s);
undo_redo(&mut s, &before, &after, history_before);
}