Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
607 lines
28 KiB
Rust
607 lines
28 KiB
Rust
//! User scenarios run headless: the real app (`SolveApp`) drawn frame by frame into an egui
|
|
//! context without a window, driven by the same requests as the control channel (clicks, keys,
|
|
//! drags, toolbar starts) the way a person would use it, with checks on the result.
|
|
//!
|
|
//! A scenario is a JSON list of steps:
|
|
//! - `{"start": "Extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}`
|
|
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
|
|
//! text (with `near`: the one in that label's row)
|
|
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
|
//! `{"drag": [AT, AT], "shift"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
|
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
|
//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an
|
|
//! error)
|
|
//! - `{"make_image": "pic.png"}`: a picture in the scenario's folder
|
|
//! - `{"choose_file": "part.step"}`: the next Open picker returns that file of the scenario's
|
|
//! folder (Save pickers save there)
|
|
//! - `{"shot": "name"}`: a screenshot when run in a window; headless, a render of the model
|
|
//! into `$SOLVECRAFT_SCENARIO_SHOTS` when that is set; `{"debug": 1}`
|
|
//! prints the UI state
|
|
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
|
|
//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`])
|
|
//! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" |
|
|
//! "close"}`: the app dies (or closes) and a new one starts on the same folders
|
|
//! - `{"note": "…"}`: a comment
|
|
//! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass
|
|
//!
|
|
//! The same files drive the live app over the control channel for screenshots.
|
|
|
|
use std::cell::RefCell;
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
|
|
use serde_json::{Value, json};
|
|
use solvecraft_engine::Session;
|
|
|
|
use crate::control::{ControlRequest, Outcome, handle};
|
|
use crate::{Services, SolveApp};
|
|
|
|
thread_local! {
|
|
/// Drag handles drawn this frame (name, screen point): manipulators publish theirs so
|
|
/// scenarios (and the control channel's `ui.at {handle}`) can grab them.
|
|
static HANDLES: RefCell<Vec<(String, egui::Pos2)>> = const { RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
/// A manipulator's handle drawn this frame at `at` (screen), by name: "arrow" (a dialog's value
|
|
/// arrow), "triad_x"… Call it from the code that draws the handle, every frame it shows.
|
|
pub fn publish_handle(name: &str, at: egui::Pos2) {
|
|
HANDLES.with(|h| {
|
|
let mut h = h.borrow_mut();
|
|
h.retain(|(n, _)| n != name);
|
|
h.push((name.to_string(), at));
|
|
});
|
|
}
|
|
|
|
/// Where a handle was drawn last frame.
|
|
pub fn handle_at(name: &str) -> Option<egui::Pos2> {
|
|
HANDLES.with(|h| h.borrow().iter().find(|(n, _)| n == name).map(|x| x.1))
|
|
}
|
|
|
|
/// Forget last frame's handles (called as a frame starts).
|
|
pub fn clear_handles() {
|
|
HANDLES.with(|h| h.borrow_mut().clear());
|
|
}
|
|
|
|
/// A fresh folder for one scenario's files.
|
|
fn scratch_dir() -> PathBuf {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
static N: AtomicUsize = AtomicUsize::new(0);
|
|
let d = std::env::temp_dir().join(format!("solvecraft-scenario-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
|
let _ = std::fs::create_dir_all(&d);
|
|
d
|
|
}
|
|
|
|
/// The app in a headless egui context.
|
|
pub struct Harness {
|
|
pub app: SolveApp,
|
|
pub ctx: egui::Context,
|
|
/// The last frame's platform output (with its accessibility tree: dialog widgets are found
|
|
/// by their text there).
|
|
pub output: egui::PlatformOutput,
|
|
/// Files the scenario saves and opens live here; file pickers answer from it.
|
|
pub dir: PathBuf,
|
|
/// The file the next Open picker returns (a `choose_file` step).
|
|
next_file: Rc<RefCell<Option<String>>>,
|
|
time: f64,
|
|
size: egui::Vec2,
|
|
}
|
|
|
|
impl Default for Harness {
|
|
fn default() -> Self {
|
|
Harness::new()
|
|
}
|
|
}
|
|
|
|
impl Drop for Harness {
|
|
/// The scenario's files go with it (kept with SOLVECRAFT_SCENARIO_KEEP set).
|
|
fn drop(&mut self) {
|
|
if std::env::var_os("SOLVECRAFT_SCENARIO_KEEP").is_none() {
|
|
let _ = std::fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Harness {
|
|
pub fn new() -> Harness {
|
|
let mut h = Harness {
|
|
app: SolveApp::new(Session::default(), Services::default()),
|
|
ctx: egui::Context::default(),
|
|
output: Default::default(),
|
|
dir: scratch_dir(),
|
|
next_file: Rc::new(RefCell::new(None)),
|
|
time: 0.0,
|
|
size: egui::vec2(1600.0, 1000.0),
|
|
};
|
|
h.install();
|
|
h.ctx.enable_accesskit();
|
|
h.frames(3);
|
|
h
|
|
}
|
|
|
|
/// File pickers answer like a person would: save into the scenario's folder, open the file
|
|
/// the scenario chose.
|
|
fn install(&mut self) {
|
|
let dir = self.dir.clone();
|
|
self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned())));
|
|
let next = self.next_file.clone();
|
|
self.app.services.pick_open = Some(Box::new(move || next.borrow_mut().take()));
|
|
}
|
|
|
|
/// Autosave into the scenario's recovery folder (as the desktop app does into the user's).
|
|
fn start_autosave(&mut self) {
|
|
crate::recovery_ui::start(&mut self.app, &self.dir.join("recovery"), std::time::Duration::ZERO);
|
|
}
|
|
|
|
/// Lay out and draw one frame (feeding queued pointer and key events).
|
|
pub fn frame(&mut self) {
|
|
self.time += 1.0 / 60.0;
|
|
let mut raw = egui::RawInput {
|
|
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size)),
|
|
time: Some(self.time),
|
|
focused: true,
|
|
..Default::default()
|
|
};
|
|
self.app.raw_input_hook(&mut raw);
|
|
let app = &mut self.app;
|
|
let mut out = self.ctx.run_ui(raw, |ui| {
|
|
app.logic(ui.ctx());
|
|
app.ui(ui);
|
|
});
|
|
// Nothing is painted: drop the frame's texture changes.
|
|
out.textures_delta.clear();
|
|
self.output = out.platform_output;
|
|
}
|
|
|
|
pub fn frames(&mut self, n: usize) {
|
|
for _ in 0..n {
|
|
self.frame();
|
|
}
|
|
}
|
|
|
|
/// Frames until the queued input is used up (and the view has settled).
|
|
pub fn settle(&mut self) {
|
|
for _ in 0..2000 {
|
|
// Queued input used up and the view no longer moving.
|
|
if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() {
|
|
break;
|
|
}
|
|
self.frame();
|
|
}
|
|
self.frames(4);
|
|
}
|
|
|
|
/// One control request, then frames until its input has been handled.
|
|
pub fn call(&mut self, method: &str, params: Value) -> Value {
|
|
let (req, _rx) = ControlRequest::new(method, params);
|
|
let out = match handle(&mut self.app, &self.ctx, &req) {
|
|
Outcome::Done(v) => v,
|
|
Outcome::Screenshot { .. } => json!({"ok": true, "result": null}),
|
|
};
|
|
self.settle();
|
|
out
|
|
}
|
|
|
|
/// The centre of the visible widget whose label or value is `text` (the last one drawn wins:
|
|
/// popups are drawn after what opened them).
|
|
pub fn widget(&self, text: &str) -> Option<[f64; 2]> {
|
|
self.widgets(text).into_iter().next()
|
|
}
|
|
|
|
/// The widget showing `text` nearest the label `near` (the field in a label's row).
|
|
pub fn widget_near(&self, text: &str, near: &str) -> Option<[f64; 2]> {
|
|
let [lx, ly] = self.widgets(near).into_iter().next()?;
|
|
self.widgets(text).into_iter().filter(|[x, _]| *x > lx).min_by(|a, b| {
|
|
let d = |p: &[f64; 2]| (p[1] - ly).abs() * 4.0 + (p[0] - lx).abs();
|
|
d(a).total_cmp(&d(b))
|
|
})
|
|
}
|
|
|
|
/// Centres of the visible widgets whose label or value is `text`, last drawn first.
|
|
fn widgets(&self, text: &str) -> Vec<[f64; 2]> {
|
|
let Some(tree) = self.output.accesskit_update.as_ref() else { return Vec::new() };
|
|
let centre =
|
|
|n: &egui::accesskit::Node| n.bounds().filter(|b| b.x1 > b.x0 && b.y1 > b.y0).map(|b| [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]);
|
|
let exact: Vec<[f64; 2]> =
|
|
tree.nodes.iter().rev().filter(|(_, n)| n.label() == Some(text) || n.value() == Some(text)).filter_map(|(_, n)| centre(n)).collect();
|
|
if !exact.is_empty() {
|
|
return exact;
|
|
}
|
|
// A menu button's label ends with its shortcut ("Save Ctrl+S").
|
|
tree.nodes
|
|
.iter()
|
|
.rev()
|
|
.filter(|(_, n)| {
|
|
n.role() == egui::accesskit::Role::Button && n.label().is_some_and(|l| l.strip_prefix(text).is_some_and(|r| r.starts_with(' ')))
|
|
})
|
|
.filter_map(|(_, n)| centre(n))
|
|
.collect()
|
|
}
|
|
|
|
/// A step's point on screen.
|
|
pub fn at(&mut self, v: &Value) -> Result<[f64; 2], String> {
|
|
if v.is_array() {
|
|
let x = v.get(0).and_then(Value::as_f64).ok_or_else(|| format!("not a point: {v}"))?;
|
|
let y = v.get(1).and_then(Value::as_f64).ok_or_else(|| format!("not a point: {v}"))?;
|
|
return Ok([x, y]);
|
|
}
|
|
let r = self.call("ui.at", v.clone());
|
|
let p = &r["result"];
|
|
match (p.get(0).and_then(Value::as_f64), p.get(1).and_then(Value::as_f64)) {
|
|
(Some(x), Some(y)) => Ok([x, y]),
|
|
_ => Err(format!("{v} is not on screen: {r}")),
|
|
}
|
|
}
|
|
|
|
/// Run one step.
|
|
pub fn step(&mut self, s: &Value) -> Result<(), String> {
|
|
let flag = |k: &str| s.get(k).and_then(Value::as_bool).unwrap_or(false);
|
|
let res =
|
|
if let Some(id) = s.get("start").and_then(Value::as_str) {
|
|
self.call("ui.start", json!({ "command": id }))
|
|
} else if let Some(k) = s.get("key").and_then(Value::as_str) {
|
|
self.call("ui.key", json!({"key": k, "shift": flag("shift"), "cmd": flag("cmd")}))
|
|
} else if let Some(t) = s.get("text").and_then(Value::as_str) {
|
|
self.call("ui.text", json!({ "text": t }))
|
|
} else if let Some(at) = s.get("click") {
|
|
let [x, y] = self.at(at)?;
|
|
// Hover first, as a hand does.
|
|
self.call("ui.move", json!({"x": x, "y": y}));
|
|
let mut p = json!({"x": x, "y": y, "double": flag("double"), "shift": flag("shift"), "ctrl": flag("ctrl")});
|
|
if let Some(b) = s.get("button") {
|
|
p["button"] = b.clone();
|
|
}
|
|
self.call("ui.click", p)
|
|
} else if let Some(text) = s.get("widget").and_then(Value::as_str) {
|
|
// A dialog field, choice or button, found by its text.
|
|
let found = match s.get("near").and_then(Value::as_str) {
|
|
Some(near) => self.widget_near(text, near),
|
|
None => self.widget(text),
|
|
};
|
|
let [x, y] = found.ok_or_else(|| format!("no widget `{text}` on screen"))?;
|
|
self.call("ui.move", json!({"x": x, "y": y}));
|
|
self.call("ui.click", json!({"x": x, "y": y}))
|
|
} else if let Some(at) = s.get("move") {
|
|
let [x, y] = self.at(at)?;
|
|
self.call("ui.move", json!({"x": x, "y": y}))
|
|
} else if let Some(d) = s.get("drag") {
|
|
let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?;
|
|
let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?;
|
|
let steps = s.get("steps").cloned().unwrap_or(json!(8));
|
|
self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift")}))
|
|
} else if let Some(m) = s.get("call").and_then(Value::as_str) {
|
|
self.call(m, s.get("params").cloned().unwrap_or(json!({})))
|
|
} else if s.get("note").is_some() {
|
|
return Ok(());
|
|
} else if let Some(name) = s.get("shot").and_then(Value::as_str) {
|
|
// With SOLVECRAFT_SCENARIO_SHOTS set: a render of the model as the camera sees it.
|
|
if let Ok(dir) = std::env::var("SOLVECRAFT_SCENARIO_SHOTS") {
|
|
let path = std::path::Path::new(&dir).join(format!("{name}.png"));
|
|
self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
|
|
}
|
|
return Ok(());
|
|
} else if s.get("autosave").is_some() {
|
|
self.start_autosave();
|
|
return Ok(());
|
|
} else if let Some(how) = s.get("restart").and_then(Value::as_str) {
|
|
// "crash": the app dies (its autosave lock goes with it, the entry stays);
|
|
// "close": it closes normally. Then a new app starts on the same folders.
|
|
let mut old = std::mem::replace(&mut self.app, SolveApp::new(Session::default(), Services::default()));
|
|
if how == "close" {
|
|
crate::recovery_ui::close(&mut old);
|
|
}
|
|
drop(old);
|
|
self.ctx = egui::Context::default();
|
|
self.ctx.enable_accesskit();
|
|
self.install();
|
|
self.start_autosave();
|
|
self.frames(3);
|
|
return Ok(());
|
|
} else if let Some(f) = s.get("make_image").and_then(Value::as_str) {
|
|
// A picture to insert (a 200 x 100 checkerboard PNG in the scenario's folder).
|
|
let img = image::RgbImage::from_fn(200, 100, |x, y| {
|
|
if (x / 20 + y / 20) % 2 == 0 { image::Rgb([230, 230, 230]) } else { image::Rgb([60, 90, 160]) }
|
|
});
|
|
img.save(self.dir.join(f)).map_err(|e| format!("make_image: {e}"))?;
|
|
return Ok(());
|
|
} else if let Some(f) = s.get("choose_file").and_then(Value::as_str) {
|
|
*self.next_file.borrow_mut() = Some(self.dir.join(f).to_string_lossy().into_owned());
|
|
return Ok(());
|
|
} else if let Some(m) = s.get("print").and_then(Value::as_str) {
|
|
let r = self.call(m, s.get("params").cloned().unwrap_or(json!({})));
|
|
eprintln!("print {m}: {r}");
|
|
return Ok(());
|
|
} else if s.get("debug").is_some() {
|
|
let ui = self.call("ui.inspect", json!({}));
|
|
let sel = self.call("ui.selection", json!({}));
|
|
eprintln!(
|
|
"debug: dialog {} tool {} hover {}\nselection {}\nstatus {:?}",
|
|
ui["result"]["dialog"], ui["result"]["tool"], ui["result"]["hover"], sel["result"]["selection"], self.app.status
|
|
);
|
|
if s["debug"] == json!("widgets") {
|
|
let texts: Vec<String> = self
|
|
.output
|
|
.accesskit_update
|
|
.iter()
|
|
.flat_map(|t| t.nodes.iter())
|
|
.filter_map(|(_, n)| {
|
|
let at = n.bounds().map(|b| format!(" @{:.0},{:.0}", (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0)).unwrap_or_default();
|
|
n.label().or(n.value()).map(|l| format!("{:?} {l}{at}", n.role()))
|
|
})
|
|
.collect();
|
|
eprintln!("widgets: {texts:?}");
|
|
}
|
|
return Ok(());
|
|
} else if let Some(e) = s.get("until") {
|
|
// Background work (a sample being built, a preview): frames until the checks pass.
|
|
let mut last = String::new();
|
|
for _ in 0..600 {
|
|
match check(self, e) {
|
|
Ok(()) => return Ok(()),
|
|
Err(m) => last = m,
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
self.frames(2);
|
|
}
|
|
return Err(format!("timed out: {last}"));
|
|
} else if let Some(e) = s.get("expect") {
|
|
return check(self, e);
|
|
} else {
|
|
return Err(format!("unknown step {s}"));
|
|
};
|
|
let failed = res["ok"] != json!(true);
|
|
if failed != flag("fail") {
|
|
return Err(format!("{s}: {res}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run a scenario (its steps); the error names the failing step.
|
|
pub fn run(&mut self, steps: &Value) -> Result<(), String> {
|
|
for (i, s) in steps.as_array().ok_or("a scenario is a list of steps")?.iter().enumerate() {
|
|
self.step(s).map_err(|e| format!("step {i}: {e}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> {
|
|
let (w, tol) = match want {
|
|
Value::Array(a) => (a.first().and_then(Value::as_f64).unwrap_or(f64::NAN), a.get(1).and_then(Value::as_f64).unwrap_or(1e-3)),
|
|
v => (v.as_f64().unwrap_or(f64::NAN), 1e-3),
|
|
};
|
|
if (got - w).abs() <= tol * w.abs().max(1.0) { Ok(()) } else { Err(format!("{what}: got {got}, want {w} (±{tol} rel)")) }
|
|
}
|
|
|
|
/// Checks: `bodies` (count), `volume` / `area` (total, number or [value, rel tol]), `faces`,
|
|
/// `features` (count), `errors` (timeline features in error, default 0), `dialog` (null: none
|
|
/// open, or a word its kind must contain), `tool` (null or command id), `sketching` (bool),
|
|
/// `selection` (count), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
|
/// ({command, params?, path, value}: a command's result).
|
|
pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
|
let doc = h.call("document.inspect", json!({"measure": true}))["result"].clone();
|
|
let ui = h.call("ui.inspect", json!({}))["result"].clone();
|
|
let sum = |k: &str| doc["bodies"].as_array().into_iter().flatten().filter_map(|b| b[k].as_f64()).sum::<f64>();
|
|
let total = json!({"volume_mm3": sum("volume_mm3"), "area_mm2": sum("area_mm2"), "faces": sum("faces") as u64});
|
|
let total = &total;
|
|
let bodies = doc["bodies"].as_array().map_or(0, Vec::len);
|
|
let obj = e.as_object().ok_or("expect takes an object")?;
|
|
for (k, v) in obj {
|
|
match k.as_str() {
|
|
"bodies" => {
|
|
if v.as_u64() != Some(bodies as u64) {
|
|
return Err(format!("bodies: got {bodies}, want {v}"));
|
|
}
|
|
}
|
|
"volume" => approx(total["volume_mm3"].as_f64().unwrap_or(f64::NAN), v, "volume")?,
|
|
"area" => approx(total["area_mm2"].as_f64().unwrap_or(f64::NAN), v, "area")?,
|
|
"faces" => {
|
|
if total["faces"] != *v {
|
|
return Err(format!("faces: got {}, want {v}", total["faces"]));
|
|
}
|
|
}
|
|
"features" => {
|
|
let n = doc["timeline"].as_array().map_or(0, Vec::len);
|
|
if v.as_u64() != Some(n as u64) {
|
|
return Err(format!("features: got {n}, want {v}"));
|
|
}
|
|
}
|
|
"errors" => {
|
|
let bad: Vec<String> = doc["timeline"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|f| !f["error"].is_null())
|
|
.map(|f| format!("{}: {}", f["name"], f["error"]))
|
|
.collect();
|
|
if v.as_u64() != Some(bad.len() as u64) {
|
|
return Err(format!("errors: {bad:?}"));
|
|
}
|
|
}
|
|
"dialog" => {
|
|
let d = &ui["dialog"];
|
|
let ok = match v {
|
|
Value::Null => d.is_null(),
|
|
Value::String(w) => d.as_str().is_some_and(|d| d.contains(w.as_str())),
|
|
_ => false,
|
|
};
|
|
if !ok {
|
|
return Err(format!("dialog: got {d}, want {v}"));
|
|
}
|
|
}
|
|
"tool" => {
|
|
if ui["tool"] != *v {
|
|
return Err(format!("tool: got {}, want {v}", ui["tool"]));
|
|
}
|
|
}
|
|
"sketching" => {
|
|
if doc["active_sketch"].is_null() == v.as_bool().unwrap_or(false) {
|
|
return Err(format!("sketching: want {v}, active sketch {}", doc["active_sketch"]));
|
|
}
|
|
}
|
|
"selection" => {
|
|
let n = doc["selection"].as_array().map_or(0, Vec::len);
|
|
if v.as_u64() != Some(n as u64) {
|
|
return Err(format!("selection: got {n}, want {v}"));
|
|
}
|
|
}
|
|
"param" => {
|
|
for (name, want) in v.as_object().into_iter().flatten() {
|
|
let p = doc["params"].as_array().into_iter().flatten().find(|p| p["name"] == json!(name)).cloned().unwrap_or_default();
|
|
approx(p["value"].as_f64().unwrap_or(f64::NAN), want, &format!("param {name}"))?;
|
|
}
|
|
}
|
|
"sketch_status" | "dof" => {
|
|
let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": {}}))["result"].clone();
|
|
let key = if k == "dof" { "dof" } else { "status" };
|
|
if si[key] != *v {
|
|
return Err(format!("{k}: got {}, want {v}", si[key]));
|
|
}
|
|
}
|
|
"file" => {
|
|
// A file the scenario saved: it exists and is not empty.
|
|
let p = h.dir.join(v.as_str().unwrap_or_default());
|
|
if std::fs::metadata(&p).map_or(0, |m| m.len()) == 0 {
|
|
return Err(format!("no file {}", p.display()));
|
|
}
|
|
}
|
|
"file_contains" => {
|
|
// {file, text}: a saved text file holds the text.
|
|
let p = h.dir.join(v["file"].as_str().unwrap_or_default());
|
|
let body = std::fs::read_to_string(&p).unwrap_or_default();
|
|
let t = v["text"].as_str().unwrap_or_default();
|
|
if !body.contains(t) {
|
|
return Err(format!("{} does not contain `{t}`", p.display()));
|
|
}
|
|
}
|
|
"hidden" => {
|
|
// Bodies hidden in the view.
|
|
let h = &ui["ui"]["hidden_bodies"];
|
|
let h = if h.is_null() { &ui["ui"]["hiddenBodies"] } else { h };
|
|
if h != v {
|
|
return Err(format!("hidden bodies: got {h}, want {v}"));
|
|
}
|
|
}
|
|
"canvas_width" => {
|
|
// The first canvas's width (mm).
|
|
let w = h.app.session.doc.canvases.first().map(|c| c.width).unwrap_or(f64::NAN);
|
|
approx(w, v, "canvas width")?;
|
|
}
|
|
"section" => {
|
|
if h.app.session.section.is_some() != v.as_bool().unwrap_or(false) {
|
|
return Err(format!("section view: want {v}"));
|
|
}
|
|
}
|
|
"camera_matches" => {
|
|
// The camera is at the saved named view (yaw, pitch, target).
|
|
let n = v.as_str().unwrap_or_default();
|
|
let nv = h.app.session.doc.named_views.iter().find(|x| x.name == n).ok_or_else(|| format!("no named view {n}"))?;
|
|
let c = &h.app.cam;
|
|
if (c.yaw - nv.yaw).abs() > 1e-6 || (c.pitch - nv.pitch).abs() > 1e-6 || c.target.dist(nv.target) > 1e-6 {
|
|
return Err(format!("camera is not at view {n}: yaw {} pitch {}, view yaw {} pitch {}", c.yaw, c.pitch, nv.yaw, nv.pitch));
|
|
}
|
|
}
|
|
"named_views" => {
|
|
let n = h.app.session.doc.named_views.len();
|
|
if v.as_u64() != Some(n as u64) {
|
|
return Err(format!("named views: got {n}, want {v}"));
|
|
}
|
|
}
|
|
"bbox" => {
|
|
// {body, min?: [x|null, y|null, z|null], max?: […]}: where a body is (±1e-3 mm).
|
|
let name = v["body"].as_str().unwrap_or_default();
|
|
// Where it is in the assembly (measured as placed).
|
|
let m = h.call("engine.execute", json!({"command": "MeasureCommand", "params": {"bodies": [name]}}))["result"].clone();
|
|
let b = m["bodies"].as_array().and_then(|a| a.first()).cloned().ok_or_else(|| format!("no body {name}: {m}"))?;
|
|
for side in ["min", "max"] {
|
|
for (i, want) in v[side].as_array().into_iter().flatten().enumerate() {
|
|
let (Some(w), Some(g)) = (want.as_f64(), b["bbox"][side].get(i).and_then(Value::as_f64)) else { continue };
|
|
if (w - g).abs() > 1e-3 {
|
|
return Err(format!("{name} bbox {side}[{i}]: got {g}, want {w}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"sketch_curves" => {
|
|
// {sketch?, linked?, own?}: curves projected (linked) and drawn in a sketch.
|
|
let mut params = json!({});
|
|
if let Some(n) = v.get("sketch") {
|
|
params["sketch"] = n.clone();
|
|
}
|
|
let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": params}))["result"].clone();
|
|
let curves = si["curves"].as_array().cloned().unwrap_or_default();
|
|
let linked = curves.iter().filter(|c| !c["link"].is_null()).count();
|
|
let own = curves.len() - linked;
|
|
for (k, got) in [("linked", linked), ("own", own)] {
|
|
if let Some(w) = v.get(k).and_then(Value::as_u64)
|
|
&& w != got as u64
|
|
{
|
|
return Err(format!("sketch curves {k}: got {got}, want {w}"));
|
|
}
|
|
}
|
|
}
|
|
"body_volume" => {
|
|
// {body, volume}: one body's volume.
|
|
let name = v["body"].as_str().unwrap_or_default();
|
|
let m = h.call("engine.execute", json!({"command": "MeasureCommand", "params": {"bodies": [name]}}))["result"].clone();
|
|
let got = m["bodies"][0]["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
|
approx(got, &v["volume"], &format!("{name} volume"))?;
|
|
}
|
|
"sketch_world" => {
|
|
// {sketch, point, at: [x,y,z]}: where a sketch point is in the world (±1e-3 mm).
|
|
let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": {"sketch": v["sketch"]}}))["result"].clone();
|
|
let p = si["points"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|p| p["id"] == v["point"])
|
|
.cloned()
|
|
.ok_or_else(|| format!("no point {}", v["point"]))?;
|
|
for i in 0..3 {
|
|
let (g, w) = (p["world"][i].as_f64().unwrap_or(f64::NAN), v["at"][i].as_f64().unwrap_or(f64::NAN));
|
|
if (g - w).abs() > 1e-3 {
|
|
return Err(format!("{} of {} is at {}, want {}", v["point"], v["sketch"], p["world"], v["at"]));
|
|
}
|
|
}
|
|
}
|
|
"home" => {
|
|
if ui["home"] != *v {
|
|
return Err(format!("start page open: got {}, want {v}", ui["home"]));
|
|
}
|
|
}
|
|
"documents" => {
|
|
if ui["documents"] != *v {
|
|
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
|
}
|
|
}
|
|
"name" => {
|
|
if doc["name"] != *v {
|
|
return Err(format!("design name: got {}, want {v}", doc["name"]));
|
|
}
|
|
}
|
|
"query" => {
|
|
// {command, params?, path: [keys or indices], value}: a command's result at path.
|
|
let cmd = v["command"].as_str().ok_or("query needs `command`")?;
|
|
let r = h.call("engine.execute", json!({"command": cmd, "params": v.get("params").cloned().unwrap_or(json!({}))}));
|
|
let mut at = &r["result"];
|
|
for k in v["path"].as_array().into_iter().flatten() {
|
|
at = match k {
|
|
Value::Number(n) => &at[n.as_u64().unwrap_or(0) as usize],
|
|
Value::String(s) => &at[s.as_str()],
|
|
_ => &Value::Null,
|
|
};
|
|
}
|
|
let want = &v["value"];
|
|
let ok = match (at.as_f64(), want.as_f64()) {
|
|
(Some(a), Some(w)) => (a - w).abs() <= 1e-6 * w.abs().max(1.0),
|
|
_ => at == want,
|
|
};
|
|
if !ok {
|
|
return Err(format!("query {cmd} {}: got {at}, want {want}", v["path"]));
|
|
}
|
|
}
|
|
other => return Err(format!("unknown check `{other}`")),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|