Files
vectorcraft/crates/engine/src/tests/linkwatch.rs
T
dotCooCoo 2c0d09e079 Scripts: File › Scripts › Other Script… runs JavaScript files
File › Scripts › Other Script… picks a .js or .jsx file, shows a warning
that can be turned off in Preferences › General, and runs the script
against the open documents through the `vc` object. The new
`script.run` and `script.stop` commands run and stop a script over MCP.

- A run is one transaction. Each document it changed gets one undo step
  named Run Script. An uncaught error, a limit or a stop puts every
  document, the paint, the clipboard, menu state and preferences back.
- A command that fails inside a run, or whose result is too large to
  reach the script, is undone and throws an error the script can catch.
- Files a command wrote stay on disk and the report names them, also
  when the run is stopped from the quit question.
- While a script runs, other commands that change anything fail with a
  busy error; MCP reports it as JSON-RPC error -32003.
- In the app the run advances one slice per frame with a Stop button in
  the status bar. A run that adds a great many objects asks whether to
  continue; Stop and Escape end it.
- Commands that change state outside the documents, such as prefs.*,
  plugin.* and file.place.queue, are refused to scripts.

Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md,
docs/target-app-parity.md.
2026-11-09 18:35:41 -08:00

187 lines
7.5 KiB
Rust

//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
//! and the background look following Preferences → File Handling → Update Links.
use std::sync::atomic::Ordering;
use serde_json::{Value, json};
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
use crate::*;
/// Write `bytes` to `path`, dated 2 s after the file it replaces so its modification time moves
/// even on coarse file systems.
fn save(path: &str, bytes: &[u8]) {
let stamp = std::fs::metadata(path).unwrap().modified().unwrap();
std::fs::write(path, bytes).unwrap();
let later = std::fs::FileTimes::new().set_modified(stamp + std::time::Duration::from_secs(2));
std::fs::OpenOptions::new().write(true).open(path).unwrap().set_times(later).unwrap();
}
fn colour(s: &Session) -> [u8; 3] {
centre_colour(&s.doc().unwrap().doc)
}
/// One background look, waited for.
fn look(s: &mut Session) -> Value {
s.start_link_scan();
s.wait_link_scan();
s.poll_link_scan().expect("a look ran").unwrap()
}
#[test]
fn update_changed_reads_changed_files_again_once() {
let dir = Folder::new("watch-update");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
let id = place(&mut s, &path);
// First look: remembered, nothing to read.
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
save(&path, &png(40, 40, BLUE));
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
// Undoing it is respected: the link stays modified until the file changes again.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colour(&s), RED);
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1);
}
#[test]
fn a_background_look_follows_update_links() {
for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] {
let dir = Folder::new(&format!("watch-{mode}"));
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = mode.into();
let id = place(&mut s, &path);
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
save(&path, &png(40, 40, BLUE));
let r = look(&mut s);
assert_eq!(r[key], json!([id.0]), "{mode}: {r}");
assert_eq!(colour(&s), if mode == "automatically" { BLUE } else { RED }, "{mode}");
// Acted on once: the next look finds nothing new.
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
assert!(s.link_scan.is_none());
}
}
#[test]
fn a_file_still_being_written_is_tried_again() {
let dir = Folder::new("watch-partial");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
// Another app has only written the start of the file so far.
let full = png(40, 40, BLUE);
save(&path, &full[..16]);
assert_eq!(look(&mut s)["updated"], json!([]));
assert_eq!(colour(&s), RED, "the last good pixels stay");
save(&path, &full);
assert_eq!(look(&mut s)["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}
#[test]
fn a_look_whose_worker_ended_without_stamps_does_not_stop_later_looks() {
let dir = Folder::new("watch-unwound");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
let scan = link_watch::LinkScan::default();
s.link_scan = Some(scan.clone());
// Still running: the look stays in flight.
assert!(s.poll_link_scan().is_none());
assert!(s.link_scan.is_some(), "a look still running stays");
let worker = std::thread::spawn(move || {
let _ended = link_watch::Ended(scan);
panic!("the look's worker unwinds");
});
assert!(worker.join().is_err());
assert!(s.poll_link_scan().is_none(), "there are no stamps to apply");
assert!(s.link_scan.is_none(), "the look ended");
save(&path, &png(40, 40, BLUE));
assert_eq!(look(&mut s)["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}
#[test]
fn a_look_that_stores_its_stamps_between_the_polls_reads_keeps_them() {
let dir = Folder::new("watch-stored");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
let uid = s.doc().unwrap().uid;
let stamps: Vec<_> = link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, ids)| (p, ids, Some((1, 1)))).collect();
let scan = link_watch::LinkScan::default();
s.link_scan = Some(scan.clone());
// The worker takes its turn after the slot is read and before the flag is, if the slot is free.
let ended = link_watch::ended_without_stamps(&scan, |flag| {
if let Ok(mut slot) = scan.0.try_lock() {
*slot = Some((uid, stamps.clone()));
flag.store(true, Ordering::SeqCst);
}
flag.load(Ordering::SeqCst)
});
assert!(!ended, "the worker waits for the poll, so the look has not ended without stamps");
// The worker's turn comes once the poll has let go of the slot.
let worker = std::thread::spawn({
let scan = scan.clone();
move || {
let _ended = link_watch::Ended(scan.clone());
*scan.0.lock().unwrap() = Some((uid, stamps));
}
});
worker.join().unwrap();
assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0]));
assert!(s.link_scan.is_none());
}
#[test]
fn a_change_during_a_script_waits_for_the_script_to_end() {
let dir = Folder::new("watch-script");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
save(&path, &png(40, 40, BLUE));
s.start_link_scan();
s.wait_link_scan();
s.script_frames = true;
s.execute("script.run", &json!({"source": "while (true) {}"})).unwrap();
assert!(s.poll_link_scan().is_none(), "the finished look waits");
assert!(s.script_abandon());
assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}
#[test]
fn a_change_during_a_drag_waits_for_the_drag_to_end() {
let dir = Folder::new("watch-drag");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
s.begin_interaction("Move").unwrap();
save(&path, &png(40, 40, BLUE));
// The preview would throw the update away, so it is not applied (nor its stamp kept).
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}));
s.cancel_interaction().unwrap();
assert_eq!(look(&mut s)["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}