UI scenarios are found, not listed: build.rs makes one test per tests/scenarios/*.json, named after the file, and a scenario waiting for a fix says so in its own file with a {"pending": "why"} step (its test is ignored with that reason). No shared list to edit, no numbering required

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 18:00:42 +00:00
1 parent 305c1c9a9b
commit acc7b83625
9 files changed
+363 -119

No files matched your search

+3
View File
@@ -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 }
+33 -1
View File
@@ -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::path::PathBuf> = 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::<serde_json::Value>(&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);
}
+3 -2
View File
@@ -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.
+5 -67
View File
@@ -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:<id> 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`.
@@ -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
}
}
]
]
@@ -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 @@
]
}
}
]
]
@@ -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"
}
]
@@ -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
}
}
}
]
@@ -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:<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}}}
{
"pending": "toolbar Pin/Remove/reorder (toolbar:<id> 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:<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
}
}
}
]