fix(engine): preserve edits when applying background analyses

Merge each offloaded property-group result against the snapshot the job started from. Preserve disjoint live edits, group identity and child changes; refuse conflicting results without changing the document or undo/redo history.

Record an apply rejection on its originating job so completion logs and waiting control commands report failure. Keep concurrent jobs' outcomes separate and retain the correct completion position when the bounded log rolls over.

Add actual-worker regressions for conflicts, disjoint edits, concurrent analyses and undo/redo, plus control completion/error regressions.

Fixes #1249

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DWAA1660
2026-10-14 21:15:40 -06:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c280506ba0
commit 2c08f675c2
5 changed files with 388 additions and 10 deletions
+3
View File
@@ -495,6 +495,8 @@ pub struct Session {
/// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one
/// (`wait: true` over the control channel in the browser) learns its outcome.
pub offload_log: Vec<(offload::JobKind, Option<String>)>,
/// Number of older completion entries evicted from the bounded offload log.
pub offload_log_start: usize,
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
pub tasks: Vec<jobs::Task>,
/// Finished tasks, newest last (Progress panel, `jobs.list`).
@@ -598,6 +600,7 @@ impl Default for Session {
offload: None,
offloaded: vec![],
offload_log: vec![],
offload_log_start: 0,
browser: None,
storage: None,
tasks: vec![],
+176 -4
View File
@@ -171,6 +171,20 @@ pub enum JobKind {
}
impl JobKind {
/// The corresponding `jobs.list` id, also used by waiting control commands.
pub fn job_id(self) -> &'static str {
match self {
JobKind::Render => "render",
JobKind::Warp => "warp",
JobKind::WarpPlan => "warpPlan",
JobKind::Camera => "camera",
JobKind::RotoFreeze | JobKind::RotoPropagate => "roto",
JobKind::Track => "track",
JobKind::MaskTrack => "maskTrack",
JobKind::ContentFill => "contentFill",
}
}
/// The undo step an analysis result becomes.
pub fn label(self) -> &'static str {
match self {
@@ -699,6 +713,9 @@ pub struct OffloadedJob {
/// Segmentations received (Roto Brush propagation).
pub segs: u64,
started: web_time::Instant,
/// The analysed group as the job started: its result is merged with what changed since
/// ([`merge_group`]).
base: Option<Box<PropGroup>>,
}
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
@@ -730,6 +747,7 @@ impl Session {
}
let id = next_id();
let inbox = Arc::new(Inbox::default());
let base = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().map(Box::new);
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
off.start(req, inbox.clone())?;
self.offloaded.push(OffloadedJob {
@@ -742,6 +760,7 @@ impl Session {
progress: OffloadProgress::default(),
segs: 0,
started: web_time::Instant::now(),
base,
});
self.events.push(Event::ProjectChanged { revision: self.revision });
Ok(())
@@ -779,7 +798,7 @@ impl Session {
j.progress.done = done;
j.progress.total = total;
}
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
WorkerReply::Group { comp, layer, group, message } => results.push((j.id, j.kind, comp, layer, group, message, j.base.clone())),
WorkerReply::Fill { plan, files } => fills.push((plan, files)),
WorkerReply::WarpPlan { key, summary } => effectcraft_effects::warp_stab::store_summary(key, *summary),
WorkerReply::Segs { segs } => {
@@ -802,7 +821,7 @@ impl Session {
Err(e) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {e}"), error: true }),
}
}
for (kind, comp, layer, group, message) in results {
for (id, kind, comp, layer, group, message, base) in results {
let uid = group.uid;
let r = self.edit(kind.label(), None, |p, _| {
let g = p
@@ -810,12 +829,31 @@ impl Session {
.and_then(|c| c.layer_mut(layer))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
*g = *group;
// Edits made while the job ran stay (#1249): only what the analysis changed is
// taken from its result.
*g = match base {
Some(base) => merge_group(&base, g, &group).ok_or_else(|| {
crate::EngineError::Other(format!(
"“{}” was changed during the analysis where its result changes it too, so nothing was applied: analyse again",
g.name
))
})?,
None => *group,
};
Ok(())
});
match r {
Ok(()) => self.events.push(Event::Toast { message, error: false }),
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
Err(e) => {
// Completion consumers read the job's error, not its toasts. Keep an apply
// rejection on the originating job; Done logs and reports it just like a
// WorkerReply::Failed, without failing another concurrent analysis.
if let Some(job) = self.offloaded.iter_mut().find(|j| j.id == id) {
job.progress.error = Some(e.to_string());
} else {
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
}
}
}
}
let mut done = vec![];
@@ -830,6 +868,7 @@ impl Session {
self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string()))));
if self.offload_log.len() > 32 {
self.offload_log.remove(0);
self.offload_log_start = self.offload_log_start.saturating_add(1);
}
if let Some(e) = p.error {
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
@@ -880,6 +919,76 @@ impl Session {
}
}
/// `live` if only it changed since `base`, `result` if only that did (or both the same way);
/// `None` when both changed it differently.
fn pick<T: PartialEq + Clone>(base: &T, live: &T, result: &T) -> Option<T> {
if live == base {
Some(result.clone())
} else if result == base || result == live {
Some(live.clone())
} else {
None
}
}
fn node_uid(n: &effectcraft_project::Node) -> Uid {
match n {
effectcraft_project::Node::Prop(p) => p.uid,
effectcraft_project::Node::Group(g) => g.uid,
}
}
/// Merge an analysis `result` into the `live` group (#1249). `base` is the group as the job
/// started. The group's own fields and each property are taken from whichever side changed
/// them, groups are merged child by child (matched by uid), and a child added or removed by one
/// side is added or removed. `None` when both sides changed the same field or property
/// differently, one side removed a child the other changed, or both reordered or added and
/// removed children differently: a conflict.
pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup) -> Option<PropGroup> {
use effectcraft_project::Node;
if live == base {
return Some(result.clone());
}
if result == base || result == live {
return Some(live.clone());
}
let header = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() };
let mut out = pick(&header(base), &header(live), &header(result))?;
let ids = |g: &PropGroup| g.children.iter().map(node_uid).collect::<Vec<_>>();
let (bi, li, ri) = (ids(base), ids(live), ids(result));
// The children's order and set: from the side that changed them.
let order = if li == bi {
ri
} else if ri == bi || ri == li {
li
} else {
return None;
};
fn find(g: &PropGroup, u: Uid) -> Option<&effectcraft_project::Node> {
g.children.iter().find(|n| node_uid(n) == u)
}
// A child one side removed must be unchanged on the other, or that side's edit would be lost.
for u in bi.iter().filter(|u| !order.contains(u)) {
let b = find(base, *u);
if [find(live, *u), find(result, *u)].into_iter().flatten().any(|n| Some(n) != b) {
return None;
}
}
for u in order {
let node = match (find(base, u), find(live, u), find(result, u)) {
(Some(Node::Group(b)), Some(Node::Group(l)), Some(Node::Group(r))) => Node::Group(merge_group(b, l, r)?),
(Some(b), Some(l), Some(r)) => pick(b, l, r)?,
// Added by one side, or by both alike.
(None, Some(l), None) => l.clone(),
(None, None, Some(r)) => r.clone(),
(None, Some(l), Some(r)) if l == r => l.clone(),
_ => return None,
};
out.children.push(node);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -892,6 +1001,69 @@ mod tests {
a
}
/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c`
/// (uid 5).
fn group() -> PropGroup {
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Node, Property};
let mut sub = PropGroup::new(4, "sub", "Sub");
sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0))));
let mut g = PropGroup::new(1, "fx", "Effect");
g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0))));
g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0))));
g.children.push(Node::Group(sub));
g
}
fn set(g: &mut PropGroup, uid: Uid, v: f64) {
g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v);
}
fn get(g: &PropGroup, uid: Uid) -> f64 {
match g.find(uid).unwrap().value {
effectcraft_keyframe::Value::Scalar(v) => v,
ref v => panic!("{v:?}"),
}
}
#[test]
fn analysis_results_merge_with_edits_made_meanwhile() {
use effectcraft_project::Node;
let base = group();
// The user changes `a` and switches the effect off; the analysis writes `b` and `c`.
let mut live = base.clone();
set(&mut live, 2, 1.0);
live.enabled = false;
let mut result = base.clone();
set(&mut result, 3, 2.0);
set(&mut result, 5, 3.0);
let m = merge_group(&base, &live, &result).unwrap();
assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false));
// Unchanged on either side: the other side wins whole.
assert_eq!(merge_group(&base, &base, &result).unwrap(), result);
assert_eq!(merge_group(&base, &live, &base).unwrap(), live);
// Both changed `b`, differently: a conflict (nothing is applied). The same way: fine.
let mut both = live.clone();
set(&mut both, 3, 9.0);
assert!(merge_group(&base, &both, &result).is_none());
set(&mut both, 3, 2.0);
assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0);
// The analysis adds a child (a result group) while the user edits `a`: both kept.
let mut added = result.clone();
added.children.push(Node::Group(PropGroup::new(6, "out", "Output")));
let m = merge_group(&base, &live, &added).unwrap();
assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0);
// The analysis drops the sub-group the user didn't touch: dropped.
let mut dropped = result.clone();
dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4));
let m = merge_group(&base, &live, &dropped).unwrap();
assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0);
// …but if the user changed something in it, that's a conflict.
let mut touched = live.clone();
set(&mut touched, 5, 7.0);
assert!(merge_group(&base, &touched, &dropped).is_none());
}
#[test]
fn protocol_round_trips() {
let mut s = Session::default();
+156
View File
@@ -412,6 +412,162 @@ impl Deferred {
}
}
/// #1249: a setting changed while the analysis runs in a worker survives its result, which
/// used to replace the whole effect with the copy the job started from.
#[test]
fn an_edit_made_during_an_offloaded_analysis_survives_its_result() {
let (mut s, clip, solid) = setup(shaky);
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap();
set_param(&mut s, clip, "stabilization/smoothness", json!(37.5));
let off = std::sync::Arc::new(Deferred::default());
s.offload = Some(off.clone());
s.execute_checked("warp.analyze", json!({"layer": clip.0})).unwrap();
// While the job runs: another setting of the same effect.
set_param(&mut s, clip, "stabilization/smoothness", json!(12.5));
let fx = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().props.prop("effects/#1/stabilization/smoothness").unwrap().value.clone();
off.run(s.footage.clone());
assert!(s.poll_offload());
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })), "applied without a conflict");
let st = s.execute("warp.status", json!({})).unwrap();
assert_eq!(st["analyzed"], true, "the analysis landed: {st}");
assert_eq!(fx(&s), KV::Scalar(12.5), "the edit made during the analysis stays");
// One undo step takes the analysis back and keeps the edit.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(fx(&s), KV::Scalar(12.5));
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
}
fn point_track_pose(f: u32) -> ([f64; 2], f64, f64) {
([150.0 + 2.0 * f as f64, 110.0 + f as f64], 0.0, 1.0)
}
/// Hold an actual point-tracking worker request so commands can edit its target before the
/// serialized worker reply is delivered.
fn held_point_track() -> (Session, LayerId, Uid, std::sync::Arc<Deferred>) {
let (mut s, clip, solid) = setup(patch_frames(point_track_pose));
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
let tracker = s.execute("track.motion", json!({})).unwrap()["tracker"].as_u64().unwrap();
s.execute("track.setPoint", json!({"point": 1, "center": point_track_pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
let off = std::sync::Arc::new(Deferred::default());
s.offload = Some(off.clone());
s.execute_checked("track.analyze", json!({"direction": "forward"})).unwrap();
assert_eq!(off.kinds(), [crate::offload::JobKind::Track]);
(s, clip, tracker, off)
}
fn tracked_center(s: &Session, clip: LayerId, tracker: Uid) -> Uid {
s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().track_points().next().unwrap().get("featureCenter").unwrap().uid
}
#[test]
fn offloaded_conflict_preserves_state_and_reports_completion_failure() {
let (mut s, clip, tracker, off) = held_point_track();
let center = tracked_center(&s, clip, tracker);
s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap();
s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Redo survives"})).unwrap();
assert!(s.undo());
let before = s.project.clone();
let state = serde_json::to_value(&s.state).unwrap();
let steps = |s: &Session| {
let entries =
|v: &[(String, std::sync::Arc<effectcraft_project::Project>)]| v.iter().map(|(label, p)| (label.clone(), p.to_json())).collect::<Vec<_>>();
(
entries(&s.history.undo),
entries(&s.history.redo),
s.history.branches.iter().map(|b| (b.parent.to_json(), entries(&b.steps))).collect::<Vec<_>>(),
s.history.merge_key.clone(),
)
};
let history = steps(&s);
let inbox = s.offloaded(crate::offload::JobKind::Track).unwrap().inbox.clone();
off.run(s.footage.clone());
let (done, replies): (Vec<_>, Vec<_>) = inbox.take().into_iter().partition(|r| matches!(r, crate::offload::WorkerReply::Done));
assert_eq!(done.len(), 1);
for reply in replies {
inbox.push(reply);
}
s.drain_events();
assert!(s.poll_offload());
let pending = s.offloaded(crate::offload::JobKind::Track).unwrap();
assert!(pending.progress.error.as_ref().is_some_and(|e| e.contains("nothing was applied")));
assert!(!pending.progress.finished);
assert!(s.offload_log.is_empty(), "the error is kept before completion is logged");
// The current completion remains identifiable after the bounded log evicts an old entry.
s.offload_log = vec![(crate::offload::JobKind::Warp, None); 32];
for reply in done {
inbox.push(reply);
}
assert!(s.poll_offload());
assert!(std::sync::Arc::ptr_eq(&s.project, &before), "a rejected result changes no document content");
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
assert_eq!(steps(&s), history);
let errors: Vec<_> = s
.drain_events()
.into_iter()
.filter_map(|e| match e {
crate::Event::Toast { error: true, message } => Some(message),
_ => None,
})
.collect();
assert_eq!(errors.len(), 1, "the apply rejection is reported once: {errors:?}");
assert!(errors[0].contains("nothing was applied"));
assert_eq!(s.offload_log.len(), 32);
assert_eq!(s.offload_log_start, 1);
let (kind, error) = s.offload_log.last().unwrap();
assert_eq!(*kind, crate::offload::JobKind::Track);
assert!(error.as_ref().is_some_and(|e| e.contains("nothing was applied")), "a rejected result must report failure to JobWaiter");
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().name, "Redo survives");
}
#[test]
fn offloaded_disjoint_edit_reports_success_and_keeps_one_undo_step() {
let (mut s, clip, tracker, off) = held_point_track();
s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Live track"})).unwrap();
s.execute("prop.setGroupEnabled", json!({"layer": clip.0, "prop": tracker, "value": false})).unwrap();
let undo = s.history.undo.len();
off.run(s.footage.clone());
s.drain_events();
assert!(s.poll_offload());
let layer = s.active_comp().unwrap().layer(clip).unwrap();
let track = layer.props.find_group(tracker).unwrap();
assert_eq!(track.name, "Live track");
assert!(!track.enabled);
assert_eq!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.len(), 25);
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(s.offload_log.last(), Some(&(crate::offload::JobKind::Track, None)));
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })));
let applied = s.project.to_json();
assert!(s.undo());
let track = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap();
assert_eq!(track.name, "Live track");
assert!(!track.enabled);
assert!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
assert!(s.redo());
assert_eq!(s.project.to_json(), applied);
}
#[test]
fn offloaded_conflict_does_not_fail_a_concurrent_analysis() {
let (mut s, clip, tracker, off) = held_point_track();
let effect = s.execute("effect.apply", json!({"effect": "Warp Stabilizer", "layers": [clip.0]})).unwrap()["effects"][0].as_u64().unwrap();
s.execute_checked("warp.analyze", json!({"layer": clip.0, "effect": effect})).unwrap();
assert_eq!(off.kinds(), [crate::offload::JobKind::Track, crate::offload::JobKind::Warp]);
let center = tracked_center(&s, clip, tracker);
s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap();
off.run(s.footage.clone());
assert!(s.poll_offload());
let track = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Track).unwrap();
assert!(track.1.as_ref().is_some_and(|e| e.contains("nothing was applied")));
let warp = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Warp).unwrap();
assert!(warp.1.is_none(), "the other analysis completed successfully: {warp:?}");
assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": effect})).unwrap()["analyzed"], true);
assert!(s.active_comp().unwrap().layer(clip).unwrap().props.find(center).unwrap().keys.is_empty());
}
/// M13.32: where jobs run elsewhere (the browser), `warp.status` never solves the
/// stabilization plan on the page's thread (seconds for a long Subspace Warp clip): the
/// analysis job sends the plan's summary with its result, and a settings change has the plan
+11 -6
View File
@@ -68,8 +68,8 @@ pub struct JobWaiter {
pub result: Value,
/// Ids of the jobs it started (`jobs.list` ids: `render`, `track`, `roto`…).
pub jobs: Vec<String>,
/// Length of [`effectcraft_engine::Session::offload_log`] when it started.
pub log_len: usize,
/// Absolute completion-log position when it started (including evicted entries).
pub log_cursor: usize,
}
/// Commands that block with `wait` (and its default).
@@ -86,7 +86,7 @@ fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<O
return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string());
}
let before: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).collect();
let log_len = app.session.offload_log.len();
let log_cursor = app.session.offload_log_start.saturating_add(app.session.offload_log.len());
let mut p = params.clone();
p["wait"] = json!(false);
let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?;
@@ -94,7 +94,7 @@ fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<O
if jobs.is_empty() {
return Ok(ok(result));
}
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_len }))
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor }))
}
impl JobWaiter {
@@ -108,8 +108,13 @@ impl JobWaiter {
pub fn finish(self, app: &mut EffectcraftApp) -> Value {
let s = &mut app.session;
s.poll_render();
let errors: Vec<String> = s.offload_log.iter().skip(self.log_len).filter_map(|(_, e)| e.clone()).collect();
if let Some(e) = errors.first() {
let error = s
.offload_log
.iter()
.skip(self.log_cursor.saturating_sub(s.offload_log_start))
.filter(|(kind, _)| self.jobs.iter().any(|id| id == kind.job_id()))
.find_map(|(_, e)| e.as_ref());
if let Some(e) = error {
return json!({"ok": false, "error": e});
}
let mut r = self.result;
+42
View File
@@ -219,3 +219,45 @@ fn waiting_commands_reply_when_their_offloaded_job_ends() {
let v = reply.try_recv().expect("immediate reply");
assert_eq!(v["result"]["rendering"], true, "{v}");
}
/// Completion errors belong to the waiting command's jobs, including when several
/// different analyses complete in the same UI frame.
#[test]
fn waiting_commands_report_only_their_own_completion_errors() {
use effectcraft_engine::offload::JobKind;
use effectcraft_ui_egui::control::JobWaiter;
for (kind, job_id) in [
(JobKind::Render, "render"),
(JobKind::Warp, "warp"),
(JobKind::WarpPlan, "warpPlan"),
(JobKind::Camera, "camera"),
(JobKind::RotoFreeze, "roto"),
(JobKind::RotoPropagate, "roto"),
(JobKind::Track, "track"),
(JobKind::MaskTrack, "maskTrack"),
(JobKind::ContentFill, "contentFill"),
] {
let other = if kind == JobKind::Track { JobKind::Warp } else { JobKind::Track };
let mut app = EffectcraftApp::new(Session::default());
app.session.offload_log = vec![(kind, Some("old error".into())), (other, Some("unrelated failure".into())), (kind, None)];
let waiter = || JobWaiter { command: "analysis".into(), params: json!({}), result: json!({"running": true}), jobs: vec![job_id.into()], log_cursor: 1 };
let result = waiter().finish(&mut app);
assert_eq!(result, json!({"ok": true, "result": {"running": false}}), "{kind:?}");
app.session.offload_log.push((kind, Some("nothing was applied".into())));
let result = waiter().finish(&mut app);
assert_eq!(result, json!({"ok": false, "error": "nothing was applied"}), "{kind:?}");
}
}
#[test]
fn waiting_command_sees_its_error_after_the_completion_log_rolls_over() {
use effectcraft_engine::offload::JobKind;
use effectcraft_ui_egui::control::JobWaiter;
let mut app = EffectcraftApp::new(Session::default());
app.session.offload_log = vec![(JobKind::Warp, None); 31];
app.session.offload_log.push((JobKind::Track, Some("nothing was applied".into())));
app.session.offload_log_start = 1;
let waiter = JobWaiter { command: "track.analyze".into(), params: json!({}), result: json!({}), jobs: vec!["track".into()], log_cursor: 32 };
assert_eq!(waiter.finish(&mut app), json!({"ok": false, "error": "nothing was applied"}));
}