diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ec0542cd..110b5b96 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,3 +17,6 @@ serde_json = { workspace = true } log = { workspace = true } web-time = { workspace = true } image = { workspace = true, features = ["jpeg"] } + +[build-dependencies] +serde_json = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 08d64f23..553f32b6 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,5 +1,6 @@ //! Build information for Help › About: the git commit and the build date (UTC). Both fall back to -//! "unknown" when git is missing (a source tarball). +//! "unknown" when git is missing (a source tarball). Also one test per UI scenario file +//! (`tests/scenarios/*.json`), so adding a scenario needs no Rust edit. use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; @@ -34,4 +35,35 @@ fn main() { println!("cargo:rerun-if-changed={dir}/logs/HEAD"); } println!("cargo:rerun-if-changed=build.rs"); + scenario_tests(); +} + +/// `$OUT_DIR/scenario_tests.rs`: a `#[test]` per scenario file, named after it; a scenario with +/// a `{"pending": "why"}` step is `#[ignore]`d with that reason (it waits for a fix). +fn scenario_tests() { + let dir = std::path::Path::new("tests/scenarios"); + println!("cargo:rerun-if-changed=tests/scenarios"); + let mut files: Vec = std::fs::read_dir(dir) + .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect()) + .unwrap_or_default(); + files.sort(); + let mut out = String::new(); + for f in files { + let Some(stem) = f.file_stem().and_then(|s| s.to_str()) else { continue }; + let mut name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect(); + if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + name.insert_str(0, "s_"); + } + let text = std::fs::read_to_string(&f).unwrap_or_default(); + let pending = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.as_array().and_then(|a| a.iter().find_map(|s| s.get("pending").map(|p| p.as_str().unwrap_or("pending").to_string())))); + if let Some(why) = pending { + out += &format!("#[test]\n#[ignore = {why:?}]\nfn {name}() {{\n run({stem:?});\n}}\n\n"); + } else { + out += &format!("#[test]\nfn {name}() {{\n run({stem:?});\n}}\n\n"); + } + } + let path = std::path::Path::new(&std::env::var("OUT_DIR").unwrap_or_else(|_| ".".into())).join("scenario_tests.rs"); + let _ = std::fs::write(path, out); } diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 3e009fa3..f79ec2cb 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -24,7 +24,8 @@ //! `{"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 +//! - `{"note": "…"}`: a comment; `{"pending": "why"}`: the scenario waits for a fix (its test +//! is ignored) //! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass //! //! The same files drive the live app over the control channel for screenshots. @@ -324,7 +325,7 @@ impl Harness { self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")})) } 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() { + } else if s.get("note").is_some() || s.get("pending").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. diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index b7e4150c..717a9042 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -1,5 +1,8 @@ //! User scenarios in the real UI, headless (see `solvecraft_ui_egui::scenario`). Each JSON file -//! under `tests/scenarios/` is one workflow done with clicks, keys and drags. +//! under `tests/scenarios/` is one workflow done with clicks, keys and drags, and one test here: +//! `build.rs` makes a test per file, named after it. A scenario waiting for a fix starts with a +//! `{"pending": "why"}` step (its test is ignored with that reason). Adding a scenario is adding +//! its file. use solvecraft_ui_egui::scenario::Harness; @@ -13,72 +16,7 @@ fn run(name: &str) { } } -macro_rules! scenarios { - ($($name:ident),* $(,)?) => { - $(#[test] - fn $name() { - run(stringify!($name)); - })* - }; -} - -scenarios!( - s01_box_fillet_hole_shell, - s02_revolve_on_xz, - s03_sketch_on_face_cut, - s04_mirror_body, - s05_delete_and_undo, - s06_edit_early_feature, - s07_components_joint_drive, - s08_sheet_metal_flat_dxf, - s09_step_roundtrip_hole, - s10_parameters_drive_model, - s11_sketch_constraints, - s12_pattern_bodies, - s13_plastic_enclosure, - s14_appearance_export, - s15_start_page_sample_save_reopen, - s16_tabs_separate_undo, - s17_toolbox_and_visibility, - s18_3d_sketch_pipe, - s19_canvas_calibrate, - s20_section_with_joint_drive, - s21_named_views, - s26_drive_joint_typed, - s22_sheet_metal_fold, - s23_configurations, - s24_motion_study, - s25_autosave_recovery, - s27_move_triad, - s33_move_x_arrow, - s34_component_move_carries_sketches, - s35_construction_mode, - s36_sketch_palette_visible, - s32_cut_in_active_component, - s30_pattern_a_hole, - s41_type_value_after_picking, - s42_drag_snap -); - -/// Scenarios written ahead of their fixes or features (the user's open bugs): run with -/// `--ignored`; each moves to the list above once its fix lands. -macro_rules! pending { - ($($name:ident: $why:literal),* $(,)?) => { - $(#[test] - #[ignore = $why] - fn $name() { - run(stringify!($name)); - })* - }; -} - -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: handles, ui.menu toolbar_command) and a full-height workspace switcher (solvecraft-shell)", -); +include!(concat!(env!("OUT_DIR"), "/scenario_tests.rs")); /// A scenario file named by `SOLVECRAFT_SCENARIO_FILE` (for trying one out): /// `SOLVECRAFT_SCENARIO_FILE=x.json cargo test --test scenarios adhoc -- --ignored --nocapture`. diff --git a/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json b/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json index 60b258cb..4b1ff328 100644 --- a/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json +++ b/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json @@ -1,4 +1,7 @@ [ + { + "pending": "user bug: a sketch on a face projects its loops as reference (solvecraft-sketch)" + }, { "note": "User bug 1 (owner solvecraft-sketch): Create Sketch on a body face projects the face's loops as reference geometry (linked), with no big square of real lines, and the face stays pickable for Project." }, @@ -138,4 +141,4 @@ "errors": 0 } } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json index 33a13cd8..9aed3414 100644 --- a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json +++ b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json @@ -1,4 +1,7 @@ [ + { + "pending": "every chevron works; the browser has no Joints folder (solvecraft-shell)" + }, { "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." }, @@ -520,4 +523,4 @@ ] } } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json b/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json index 996fd8ce..bf6ce649 100644 --- a/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json +++ b/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json @@ -1,20 +1,77 @@ [ - {"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"} + { + "pending": "the last frame of an animation to Top turns the view 22.5 degrees (solvecraft-ui)" + }, + { + "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" + } ] diff --git a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json b/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json index 1e0d1689..7857b5ec 100644 --- a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json +++ b/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json @@ -1,16 +1,113 @@ [ - {"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}}} + { + "pending": "view cube edge and corner hover light 1 patch, Fusion lights 2 and 3 (solvecraft-ui)" + }, + { + "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 + } + } + } ] diff --git a/crates/ui-egui/tests/scenarios/s40_toolbar_customize_persists.json b/crates/ui-egui/tests/scenarios/s40_toolbar_customize_persists.json index 6a4d5ccc..de43ffc5 100644 --- a/crates/ui-egui/tests/scenarios/s40_toolbar_customize_persists.json +++ b/crates/ui-egui/tests/scenarios/s40_toolbar_customize_persists.json @@ -1,17 +1,127 @@ [ - {"note": "Toolbar customizing like Fusion survives a restart: Pin to Toolbar, Remove from Toolbar, drag to reorder. Toolbar buttons publish handles `toolbar:`. 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}}} + { + "pending": "toolbar Pin/Remove/reorder (toolbar: handles, ui.menu toolbar_command) and a full-height workspace switcher (solvecraft-shell)" + }, + { + "note": "Toolbar customizing like Fusion survives a restart: Pin to Toolbar, Remove from Toolbar, drag to reorder. Toolbar buttons publish handles `toolbar:`. 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 + } + } + } ]