QA targets for the newest UX reports (pending until their owners land): s37 browser chevrons on every node kind (all toggle; no Joints folder yet), s38 view animations to Top/Bottom end without a jump (the last frame turns 22.5 degrees today), s39 view cube edge/corner hover lights 2/3 patches, s40 toolbar Pin/Remove/reorder survive a restart and the workspace switcher spans the toolbar; ui_layout tests: every command dialog docked right at 260-380 px (or 480-560 for tables; the exploded view is 635), other windows fit on screen. Harness: published rects and counts, browser rows/fold arrows as handles, camera and view_anim steps, ui.at cube points, settings carried over a close restart, an adhoc scenario runner

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 16:07:33 +00:00
1 parent 1e8fc25976
commit c45f3173fa
11 files changed
+831 -9

No files matched your search

+2
View File
@@ -281,6 +281,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked();
crate::scenario::publish_handle(&format!("fold:{}", row.label), ar.center());
}
x += 14.0;
// Eye.
@@ -322,6 +323,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
badge(ui.painter(), br, b, t.text_dim);
bx += 15.0;
}
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
out.resp = Some(resp.clone());
out.clicked = resp.clicked();
out.double = resp.double_clicked();
+6
View File
@@ -264,6 +264,12 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
};
}
let Some(rect) = app.viewport.rect else { return err("no viewport") };
if let Some(c) = p.get("cube") {
// A point on the view cube, in cube units (faces at ±1).
let n = |i: usize| c.get(i).and_then(Value::as_f64).unwrap_or(0.0);
let q = crate::viewport::cube_point(app, rect, solvecraft_engine::geom::Vec3::new(n(0), n(1), n(2)));
return ok(json!([q.x, q.y]));
}
let proj = crate::viewport::projection(app, rect);
let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0);
let world = if let Some(w) = p.get("world") {
+147 -8
View File
@@ -16,7 +16,10 @@
//! 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
//! prints the UI state (`"widgets"`: also every widget's text; `"handles"`: every published
//! handle, browser rows as `row:<label>` and their fold arrows as `fold:<label>`)
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
//! - 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" |
@@ -40,26 +43,47 @@ 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()) };
/// Painted parts with no widget of their own (the toolbar, the workspace switcher…): rects
/// and counts by name, for checks.
static RECTS: RefCell<Vec<(String, egui::Rect)>> = const { RefCell::new(Vec::new()) };
static COUNTS: RefCell<Vec<(String, f64)>> = 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));
});
// The latest wins (lookups search from the end), so publishing is cheap for browser rows.
HANDLES.with(|h| h.borrow_mut().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))
HANDLES.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// Forget last frame's handles (called as a frame starts).
/// A painted part's rect this frame, by name ("toolbar", "workspace_switcher").
pub fn publish_rect(name: &str, r: egui::Rect) {
RECTS.with(|h| h.borrow_mut().push((name.to_string(), r)));
}
pub fn rect_of(name: &str) -> Option<egui::Rect> {
RECTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// A number about this frame's drawing, by name ("cube_highlights": the view cube's lit patches).
pub fn publish_count(name: &str, v: f64) {
COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v)));
}
pub fn count_of(name: &str) -> Option<f64> {
COUNTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// Forget last frame's handles, rects and counts (called as a frame starts).
pub fn clear_handles() {
HANDLES.with(|h| h.borrow_mut().clear());
RECTS.with(|h| h.borrow_mut().clear());
COUNTS.with(|h| h.borrow_mut().clear());
}
/// A fresh folder for one scenario's files.
@@ -232,6 +256,36 @@ impl Harness {
}
}
/// Animate the view to `v` (as the view cube does), sampling the camera every frame: no
/// frame may turn the view (its right or back vector) by more than `max_deg`, the last
/// included, so the move ends without a jump.
fn view_anim(&mut self, v: &str, max_deg: f64) -> Result<(), String> {
let basis = |c: &solvecraft_engine::render::Camera| c.basis();
let mut prev = basis(&self.app.cam);
self.app.animate_view(v);
if self.app.cam_anim.is_none() {
return Err(format!("view_anim {v}: no animation started"));
}
let mut worst = (0.0f64, 0usize);
for i in 0..240 {
self.frame();
let now = basis(&self.app.cam);
let ang = |a: solvecraft_engine::geom::Vec3, b: solvecraft_engine::geom::Vec3| a.dot(b).clamp(-1.0, 1.0).acos().to_degrees();
let d = ang(prev.0, now.0).max(ang(prev.2, now.2));
if d > worst.0 {
worst = (d, i);
}
prev = now;
if self.app.cam_anim.is_none() {
if worst.0 > max_deg {
return Err(format!("view_anim {v}: frame {} of {} turns the view {:.1}° (limit {max_deg}°)", worst.1 + 1, i + 1, worst.0));
}
return Ok(());
}
}
Err(format!("view_anim {v}: still animating after 240 frames"))
}
/// 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);
@@ -279,6 +333,19 @@ impl Harness {
self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
}
return Ok(());
} else if let Some(c) = s.get("camera") {
// Put the camera at an orbit: {"yaw": rad, "pitch": rad}.
if let Some(y) = c.get("yaw").and_then(Value::as_f64) {
self.app.cam.yaw = y;
}
if let Some(p) = c.get("pitch").and_then(Value::as_f64) {
self.app.cam.pitch = p;
}
self.app.cam_anim = None;
self.frames(2);
return Ok(());
} else if let Some(v) = s.get("view_anim").and_then(Value::as_str) {
return self.view_anim(v, s.get("max_deg").and_then(Value::as_f64).unwrap_or(10.0));
} else if s.get("autosave").is_some() {
self.start_autosave();
return Ok(());
@@ -288,6 +355,9 @@ impl Harness {
let mut old = std::mem::replace(&mut self.app, SolveApp::new(Session::default(), Services::default()));
if how == "close" {
crate::recovery_ui::close(&mut old);
// The desktop saves its settings on close and loads them on the next start.
let prefs = old.prefs();
self.app.load_prefs(&prefs);
}
drop(old);
self.ctx = egui::Context::default();
@@ -330,6 +400,10 @@ impl Harness {
.collect();
eprintln!("widgets: {texts:?}");
}
if s["debug"] == json!("handles") {
let names: Vec<String> = HANDLES.with(|h| h.borrow().iter().map(|(n, p)| format!("{n} @{:.0},{:.0}", p.x, p.y)).collect());
eprintln!("handles: {names:?}");
}
return Ok(());
} else if let Some(e) = s.get("until") {
// Background work (a sample being built, a preview): frames until the checks pass.
@@ -417,6 +491,71 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("errors: {bad:?}"));
}
}
"shown_handles" | "hidden_handles" => {
// Published handles ("fold:Bodies", "row:Body1") drawn (or not) this frame.
for n in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
if handle_at(n).is_some() != (k == "shown_handles") {
return Err(format!("{n}: want {}", if k == "shown_handles" { "shown" } else { "hidden" }));
}
}
}
"handle_order" => {
// Published handles left to right in this order.
let xs: Vec<(String, Option<f32>)> =
v.as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), handle_at(n).map(|p| p.x))).collect();
if xs.iter().any(|x| x.1.is_none()) || xs.windows(2).any(|w| w[0].1 >= w[1].1) {
return Err(format!("handle order: got {xs:?}"));
}
}
"count" => {
// {"count": {"name": n, "value": x}}: a published count.
let n = v["name"].as_str().unwrap_or_default();
let got = count_of(n);
if got != v["value"].as_f64() {
return Err(format!("count {n}: got {got:?}, want {}", v["value"]));
}
}
"spans" => {
// {"spans": {"inner": a, "outer": b, "axis": "y", "tol": px}}: published rect a
// covers b's extent along the axis (within tol).
let (a, b) = (v["inner"].as_str().unwrap_or_default(), v["outer"].as_str().unwrap_or_default());
let (ra, rb) = (rect_of(a).ok_or(format!("no rect {a}"))?, rect_of(b).ok_or(format!("no rect {b}"))?);
let tol = v["tol"].as_f64().unwrap_or(4.0) as f32;
let (lo, hi) = if v["axis"] == "x" { (ra.x_range(), rb.x_range()) } else { (ra.y_range(), rb.y_range()) };
if (lo.min - hi.min).abs() > tol || (lo.max - hi.max).abs() > tol {
return Err(format!("{a} {lo:?} does not span {b} {hi:?} (±{tol})"));
}
}
"dialog_docked" => {
// The command dialog: docked at the viewport's right, its width in [min, max].
let r = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_dialog"))).ok_or("no command dialog")?;
let vp = h.app.viewport.rect.unwrap_or(egui::Rect::NOTHING);
let (lo, hi) = (v["min"].as_f64().unwrap_or(260.0) as f32, v["max"].as_f64().unwrap_or(380.0) as f32);
if r.width() < lo - 0.5 || r.width() > hi + 0.5 {
return Err(format!("dialog width {:.0}, want {lo}..{hi}", r.width()));
}
if (r.right() - vp.right()).abs() > 2.0 || r.top() < vp.top() || r.bottom() > vp.bottom() + 1.0 {
return Err(format!("dialog {r:?} is not docked right inside the viewport {vp:?}"));
}
}
"windows_fit" => {
// Every open window lies on screen and is at most `max_width` wide.
let screen = h.ctx.content_rect();
let max_w = v["max_width"].as_f64().unwrap_or(720.0) as f32;
let rects: Vec<(egui::Id, egui::Rect)> = h.ctx.memory(|m| {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|l| l.order == egui::Order::Middle)
.filter_map(|l| m.area_rect(l.id).map(|r| (l.id, r)))
.collect()
});
for (id, r) in rects {
if r.width() > max_w || !screen.expand(1.0).contains_rect(r) {
return Err(format!("window {id:?} at {r:?} (width {:.0}) does not fit {screen:?} at ≤{max_w}", r.width()));
}
}
}
"dialog" => {
let d = &ui["dialog"];
let ok = match v {
+2
View File
@@ -243,6 +243,8 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let tabs_y = r.top() + 2.0;
let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0));
painter.rect(ws, 4.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
crate::scenario::publish_rect("toolbar", r);
crate::scenario::publish_rect("workspace_switcher", ws);
painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text);
caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text);
painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
+13 -1
View File
@@ -1771,11 +1771,21 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
}
/// Where a point in view-cube units (faces at ±1) is drawn.
pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
let (r, u, _) = app.cam.basis();
pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32)
}
/// Half a view cube face, in pixels.
const CUBE_SCALE: f64 = 32.0;
/// The view cube (top right): click a face for a standard view; the house resets.
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
let s = 32.0f64;
let s = CUBE_SCALE;
let mut lit = 0;
let (r, u, b) = app.cam.basis();
let to2 = |p: Vec3| -> (Pos2, f64) { (pos2(c.x + (p.dot(r) * s) as f32, c.y - (p.dot(u) * s) as f32), p.dot(b)) };
let faces: [(Vec3, &str, StandardView); 6] = [
@@ -1820,6 +1830,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let ((a0, a1), (b0, b1)) = (lo(zi), lo(zj));
let zp: Vec<Pos2> = [(a0, b0), (a1, b0), (a1, b1), (a0, b1)].iter().map(|(x, y)| to2(n + e1 * *x + e2 * *y).0).collect();
painter.add(Shape::convex_polygon(zp, t.accent_soft, Stroke::NONE));
lit += 1;
}
let center = to2(n).0;
if n.dot(b) > 0.35 {
@@ -1831,6 +1842,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
clicked = Some(n + e1 * zi as f64 + e2 * zj as f64);
}
}
crate::scenario::publish_count("cube_highlights", f64::from(lit));
// Axis triad at the cube's corner.
let o = Vec3::new(-1.0, -1.0, -1.0);
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
+18
View File
@@ -72,4 +72,22 @@ macro_rules! pending {
pending!(
s31_sketch_on_face_projects: "user bug: a sketch on a face projects its loops as reference (solvecraft-sketch)",
s37_browser_chevrons: "every chevron works; the browser has no Joints folder (solvecraft-shell)",
s38_view_anim_no_jump: "the last frame of an animation to Top turns the view 22.5 degrees (solvecraft-ui)",
s39_view_cube_hover: "view cube edge and corner hover light 1 patch, Fusion lights 2 and 3 (solvecraft-ui)",
s40_toolbar_customize_persists: "toolbar Pin/Remove/reorder (toolbar:<id> handles, ui.menu toolbar_command) and a full-height workspace switcher (solvecraft-shell)",
);
/// A scenario file named by `SOLVECRAFT_SCENARIO_FILE` (for trying one out):
/// `SOLVECRAFT_SCENARIO_FILE=x.json cargo test --test scenarios adhoc -- --ignored --nocapture`.
#[test]
#[ignore]
fn adhoc() {
let Ok(path) = std::env::var("SOLVECRAFT_SCENARIO_FILE") else { return };
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
let steps: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
let mut h = Harness::new();
if let Err(e) = h.run(&steps) {
panic!("{path}: {e}");
}
}
@@ -0,0 +1,523 @@
[
{
"note": "Browser chevrons expand and collapse every kind of node (folders, components, groups, the origin, named views, document settings). Model: two components with a body each, a joint, a sketch, an offset plane, a named view."
},
{
"start": "FusionCreateNewComponentCommand"
},
{
"key": "Enter"
},
{
"start": "SketchCreate"
},
{
"click": {
"plane": "XY"
}
},
{
"start": "ShapeRectangleTwoPoint"
},
{
"click": {
"sketch": [
0,
0
]
}
},
{
"click": {
"sketch": [
20,
20
]
}
},
{
"key": "Escape"
},
{
"start": "SketchStop"
},
{
"call": "ui.view",
"params": {
"view": "iso"
}
},
{
"start": "Extrude"
},
{
"text": "10"
},
{
"key": "Enter"
},
{
"call": "ui.menu",
"params": {
"x": 100,
"y": 170,
"target": {
"type": "component",
"id": 0
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Activate"
}
},
{
"start": "FusionCreateNewComponentCommand"
},
{
"key": "Enter"
},
{
"start": "SketchCreate"
},
{
"click": {
"plane": "XZ"
}
},
{
"start": "ShapeRectangleTwoPoint"
},
{
"click": {
"sketch": [
30,
0
]
}
},
{
"click": {
"sketch": [
50,
10
]
}
},
{
"key": "Escape"
},
{
"start": "SketchStop"
},
{
"call": "ui.view",
"params": {
"view": "iso"
}
},
{
"start": "Extrude"
},
{
"click": {
"world": [
40,
0,
5
]
}
},
{
"text": "20"
},
{
"key": "Enter"
},
{
"expect": {
"bodies": 2,
"errors": 0
}
},
{
"start": "JointAssembleCmdNew"
},
{
"click": {
"world": [
10,
10,
10
]
}
},
{
"click": {
"world": [
40,
0,
5
]
}
},
{
"widget": "Rigid"
},
{
"widget": "Revolute"
},
{
"widget": "OK"
},
{
"call": "engine.execute",
"params": {
"command": "ConstructionPlaneOffsetFromPlaneCommand",
"params": {
"base": "XY",
"offset": 30
}
}
},
{
"key": "Escape"
},
{
"call": "ui.menu",
"params": {
"x": 100,
"y": 300,
"target": {
"type": "named_views"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "New Named View"
}
},
{
"note": "Each kind of node: its chevron hides its children, and shows them again."
},
{
"click": {
"handle": "fold:Document Settings"
}
},
{
"expect": {
"shown_handles": [
"row:Units: mm"
]
}
},
{
"click": {
"handle": "fold:Document Settings"
}
},
{
"expect": {
"hidden_handles": [
"row:Units: mm"
]
}
},
{
"click": {
"handle": "fold:Document Settings"
}
},
{
"expect": {
"shown_handles": [
"row:Units: mm"
]
}
},
{
"expect": {
"shown_handles": [
"row:Named View1"
]
}
},
{
"click": {
"handle": "fold:Named Views"
}
},
{
"expect": {
"hidden_handles": [
"row:Named View1"
]
}
},
{
"click": {
"handle": "fold:Named Views"
}
},
{
"expect": {
"shown_handles": [
"row:Named View1"
]
}
},
{
"click": {
"handle": "fold:Origin"
}
},
{
"expect": {
"shown_handles": [
"row:XY"
]
}
},
{
"click": {
"handle": "fold:Origin"
}
},
{
"expect": {
"hidden_handles": [
"row:XY"
]
}
},
{
"click": {
"handle": "fold:Origin"
}
},
{
"expect": {
"shown_handles": [
"row:XY"
]
}
},
{
"expect": {
"shown_handles": [
"row:Body2"
]
}
},
{
"click": {
"handle": "fold:Bodies"
}
},
{
"expect": {
"hidden_handles": [
"row:Body2"
]
}
},
{
"click": {
"handle": "fold:Bodies"
}
},
{
"expect": {
"shown_handles": [
"row:Body2"
]
}
},
{
"expect": {
"shown_handles": [
"row:Sketch2"
]
}
},
{
"click": {
"handle": "fold:Sketches"
}
},
{
"expect": {
"hidden_handles": [
"row:Sketch2"
]
}
},
{
"click": {
"handle": "fold:Sketches"
}
},
{
"expect": {
"shown_handles": [
"row:Sketch2"
]
}
},
{
"expect": {
"shown_handles": [
"row:Plane1"
]
}
},
{
"click": {
"handle": "fold:Construction"
}
},
{
"expect": {
"hidden_handles": [
"row:Plane1"
]
}
},
{
"click": {
"handle": "fold:Construction"
}
},
{
"expect": {
"shown_handles": [
"row:Plane1"
]
}
},
{
"expect": {
"shown_handles": [
"row:Sketch2"
]
}
},
{
"click": {
"handle": "fold:Component2:1"
}
},
{
"expect": {
"hidden_handles": [
"row:Sketch2"
]
}
},
{
"click": {
"handle": "fold:Component2:1"
}
},
{
"expect": {
"shown_handles": [
"row:Sketch2"
]
}
},
{
"expect": {
"shown_handles": [
"row:Sketch1"
]
}
},
{
"click": {
"handle": "fold:Component1:1"
}
},
{
"expect": {
"hidden_handles": [
"row:Sketch1"
]
}
},
{
"click": {
"handle": "fold:Component1:1"
}
},
{
"expect": {
"shown_handles": [
"row:Sketch1"
]
}
},
{
"expect": {
"shown_handles": [
"row:Origin"
]
}
},
{
"click": {
"handle": "fold:Untitled"
}
},
{
"expect": {
"hidden_handles": [
"row:Origin"
]
}
},
{
"click": {
"handle": "fold:Untitled"
}
},
{
"expect": {
"shown_handles": [
"row:Origin"
]
}
},
{
"note": "The joints folder lists the joint (Fusion's Joints folder in the root component)."
},
{
"expect": {
"shown_handles": [
"fold:Joints"
]
}
},
{
"click": {
"handle": "fold:Joints"
}
},
{
"click": {
"handle": "fold:Joints"
}
},
{
"expect": {
"shown_handles": [
"fold:Joints"
]
}
}
]
@@ -0,0 +1,20 @@
[
{"note": "Animating to Top or Bottom from iso, front or any orbit turns the view smoothly to the end: no frame (the last included) turns it more than 10 degrees."},
{"start": "PrimitiveBox"},
{"key": "Escape"},
{"view_anim": "top"},
{"view_anim": "home"},
{"view_anim": "bottom"},
{"call": "ui.view", "params": {"view": "front"}},
{"view_anim": "top"},
{"call": "ui.view", "params": {"view": "front"}},
{"view_anim": "bottom"},
{"camera": {"yaw": 2.3, "pitch": 0.35}},
{"view_anim": "top"},
{"camera": {"yaw": -1.1, "pitch": -0.4}},
{"view_anim": "top"},
{"camera": {"yaw": 2.3, "pitch": 0.35}},
{"view_anim": "bottom"},
{"camera": {"yaw": 0.4, "pitch": 1.2}},
{"view_anim": "front"}
]
@@ -0,0 +1,16 @@
[
{"note": "View cube hover from the home view: a face lights one patch, an edge the 2 patches on its faces, a corner the 3 around it (Fusion)."},
{"call": "ui.view", "params": {"view": "home"}},
{"move": {"cube": [0, 0, 1]}},
{"expect": {"count": {"name": "cube_highlights", "value": 1}}},
{"move": {"cube": [0, -0.9, 1]}},
{"expect": {"count": {"name": "cube_highlights", "value": 2}}},
{"move": {"cube": [1, -0.9, 0]}},
{"expect": {"count": {"name": "cube_highlights", "value": 2}}},
{"move": {"cube": [0.9, -0.9, 1]}},
{"expect": {"count": {"name": "cube_highlights", "value": 3}}},
{"move": {"cube": [0.9, -1, 0.9]}},
{"expect": {"count": {"name": "cube_highlights", "value": 3}}},
{"move": [800, 500]},
{"expect": {"count": {"name": "cube_highlights", "value": 0}}}
]
@@ -0,0 +1,17 @@
[
{"note": "Toolbar customizing like Fusion survives a restart: Pin to Toolbar, Remove from Toolbar, drag to reorder. Toolbar buttons publish handles `toolbar:<command id>`. The workspace switcher spans the toolbar's height."},
{"expect": {"spans": {"inner": "workspace_switcher", "outer": "toolbar", "axis": "y", "tol": 6}}},
{"expect": {"shown_handles": ["toolbar:Extrude", "toolbar:Revolve"], "hidden_handles": ["toolbar:FusionShellBodyCommand"]}},
{"call": "ui.menu", "params": {"x": 400, "y": 80, "target": {"type": "toolbar_command", "id": "FusionShellBodyCommand"}}},
{"call": "ui.menuPick", "params": {"item": "Pin to Toolbar"}},
{"expect": {"shown_handles": ["toolbar:FusionShellBodyCommand"]}},
{"call": "ui.menu", "params": {"x": 400, "y": 80, "target": {"type": "toolbar_command", "id": "Revolve"}}},
{"call": "ui.menuPick", "params": {"item": "Remove from Toolbar"}},
{"expect": {"hidden_handles": ["toolbar:Revolve"]}},
{"drag": [{"handle": "toolbar:FusionShellBodyCommand"}, {"handle": "toolbar:Extrude"}], "steps": 12},
{"expect": {"handle_order": ["toolbar:FusionShellBodyCommand", "toolbar:Extrude"]}},
{"restart": "close"},
{"expect": {"shown_handles": ["toolbar:FusionShellBodyCommand"], "hidden_handles": ["toolbar:Revolve"]}},
{"expect": {"handle_order": ["toolbar:FusionShellBodyCommand", "toolbar:Extrude"]}},
{"expect": {"spans": {"inner": "workspace_switcher", "outer": "toolbar", "axis": "y", "tol": 6}}}
]
+67
View File
@@ -0,0 +1,67 @@
//! Layout checks over every toolbar command, headless (see `solvecraft_ui_egui::scenario`):
//! each command's dialog opens docked at the viewport's right within its width limits, and
//! every window it opens fits on screen.
use serde_json::{Value, json};
use solvecraft_ui_egui::scenario::{Harness, check};
/// A box body (its sketch finished) to give feature dialogs something to work on.
fn harness() -> Harness {
let mut h = Harness::new();
for (id, p) in [
("SketchCreate", json!({"plane": "XY"})),
("ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})),
("SketchStop", json!({})),
("Extrude", json!({"distance": 10})),
] {
h.call("engine.execute", json!({"command": id, "params": p}));
}
h.frames(3);
h
}
#[test]
#[ignore = "pending: the exploded view dialog opens 635 px wide (solvecraft-ui)"]
fn every_dialog_opens_docked_within_its_width_limits() {
let mut bad = Vec::new();
let mut seen = 0;
for spec in solvecraft_engine::command_specs() {
if spec.tab.is_empty() || spec.tab == "SKETCH" {
continue;
}
let mut h = harness();
h.call("ui.start", json!({"command": spec.id}));
h.frames(4);
if h.app.dialog.is_some() {
seen += 1;
// Command dialogs are 260–380 px (plus their 1 px border), the few with tables
// (motion study, configurations) 480–560.
let w = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_dialog"))).map_or(0.0, |r| r.width());
let limits = if w > 400.0 { json!({"min": 480, "max": 562}) } else { json!({"min": 260, "max": 382}) };
if let Err(e) = check(&mut h, &json!({ "dialog_docked": limits })) {
bad.push(format!("{}: {e}", spec.id));
}
}
if let Err(e) = check(&mut h, &json!({"windows_fit": {"max_width": 900}})) {
bad.push(format!("{}: {e}", spec.id));
}
}
assert!(seen > 20, "only {seen} commands opened a dialog");
assert!(bad.is_empty(), "{} problems:\n{}", bad.len(), bad.join("\n"));
}
/// Windows that are not command dialogs: each fits on screen.
#[test]
fn other_windows_fit_on_screen() {
let mut bad = Vec::new();
let opens: [(&str, Value); 3] = [("ui.help", json!({"item": "about"})), ("ui.key", json!({"key": "S"})), ("ui.prefs", json!({"open": true}))];
for (m, p) in opens {
let mut h = harness();
h.call(m, p.clone());
h.frames(4);
if let Err(e) = check(&mut h, &json!({"windows_fit": {"max_width": 900}})) {
bad.push(format!("{m} {p}: {e}"));
}
}
assert!(bad.is_empty(), "{}", bad.join("\n"));
}