diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dadf72..3b640ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: pull_request: push: branches: [main] + schedule: + # Nightly: the heavy tests on main. + - cron: '23 3 * * *' workflow_dispatch: permissions: @@ -65,8 +68,26 @@ jobs: components: rustfmt, clippy targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # Branches restore main's cache; saving their own would only evict it. + save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo xtask ci + heavy: + # The heavy verification and corpus tests (#[ignore]d) and the doc tests: on main, nightly + # and on demand, not on every pull request. + name: cargo xtask test --heavy + if: contains(fromJSON('["push", "schedule", "workflow_dispatch"]'), github.event_name) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo xtask test --heavy + advisories: name: Dependency advisories and sources needs: changes diff --git a/AGENTS.md b/AGENTS.md index ca61b31..a5bf7c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,14 @@ People trust ModelCraft with their models; a crash loses their work. **This outr warnings, tests, wasm) on every PR; don't run the full gate locally. Before pushing: `cargo fmt --all`, `cargo check` the touched crates, and run the tests you added or changed. Commit after each working step (a layer that compiles with its tests passing), not one big commit per PR. +- **Keep the PR gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds + (convergence studies, the whole corpus, cross-checks between solvers) is + `#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]`, with a fast representative + test left in the PR gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored + test, benchmarks included, and the doc tests) on pushes to `main`, nightly and on demand + (`gh workflow run CI --ref `). Hostile-input tests run commands on sessions with small + work caps (`Session::limits`) so refusals are instant; never lower the production caps for a + test. - **Publishing**: `origin` is github.com/jyviko/modelcraft. Changes land through pull requests: branch, **rebase onto the latest `origin/main` right before opening the PR and before every push that updates it** (never merge `main` into a branch), push, open a PR, let CI run. A PR merges diff --git a/README.md b/README.md index 0c13993..e493e57 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ geometry boundary (SolidCraft's kernel and tessellator) are in place; meshing is ```sh cargo xtask ci # fmt, assets, licences, layers, clippy, tests, wasm +cargo xtask test --heavy # the heavy (ignored) tests and the doc tests cargo run -p modelcraft-cli -- commands # every command and its parameters cargo run -p modelcraft-cli -- run script.json # run a command script ``` diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5397843..1cab569 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -154,9 +154,11 @@ impl Session { (session, warnings) } - /// Replace this session with `opened`, keeping its undo memory limit; the history starts empty. + /// Replace this session with `opened`, keeping its undo memory limit and its work caps; the + /// history starts empty. fn replace_with(&mut self, mut opened: Session) { opened.history = std::mem::take(&mut self.history); + opened.limits = self.limits; opened.clear_history(); *self = opened; } diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 2bd8ef0..1e41f33 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -126,10 +126,9 @@ fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result { } fn tet_generate(s: &mut Session, p: &Value) -> Result { - let defaults = CdtOptions::default(); let cdt_options = CdtOptions { - max_steiner: optional_count(p, "max_steiner", defaults.max_steiner as u64, 0..=TET_MAX_STEINER)?, - max_tets: optional_count(p, "max_tets", defaults.max_tets as u64, 1..=TET_MAX_TETS)?, + max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, + max_tets: optional_count(p, "max_tets", s.limits.max_tets as u64, 1..=TET_MAX_TETS)?, }; let (surface, body, face_names) = { let Input { mesh, body, faces } = surface_input(s, p)?; @@ -648,6 +647,26 @@ mod tests { assert_eq!((s.journal.len(), &s.model), (journal, &model), "failures change nothing"); } + /// The session's caps are the defaults of `max_steiner` and `max_tets`; a parameter overrides them. + #[test] + fn tet_generate_honours_the_session_caps() { + use std::sync::Arc; + + use modelcraft_geometry::open_design; + use modelcraft_geometry::sample::{Plate, plate_design}; + + use crate::{EngineError, Limits}; + + let design = Arc::new(open_design(&plate_design(&Plate::default()).unwrap()).unwrap()); + let mut s = Session { design: Some(design), limits: Limits { max_tets: 10, ..Limits::default() }, ..Session::default() }; + let plate = json!({"body": "Body1", "tolerance": 0.5}); + let refused = s.execute("mesh.tet.generate", &plate).unwrap_err(); + assert!(matches!(refused, EngineError::Mesh(modelcraft_mesh::Error::Limit(_))), "{refused}"); + assert!(s.model.elements().is_empty()); + let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5, "max_tets": 1_000_000})).unwrap(); + assert!(r["elements"].as_u64().unwrap() > 0, "{r}"); + } + #[test] fn tet_generate_on_an_imported_surface_and_an_open_one() { use std::sync::Arc; diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index 3a0aa79..54ce8da 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -792,6 +792,7 @@ pub(crate) mod tests { /// bending energy, so without hourglass stiffness (viscous control) the period is longer by /// (1 − 1/n²)^{−½} (3.3% for n = 4); the stiffness form restores part of it. #[test] + #[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn explicit_cantilever_period_matches_the_modal_frequency() { let (length, n) = (10.0, [40usize, 4usize]); let mut s = Session::default(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dcce9d3..c25fc74 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -124,6 +124,25 @@ pub(crate) fn next_revision() -> u64 { NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } +/// The defaults of the work caps that commands take as parameters: what a command may spend +/// when its parameters name no cap of their own. A parameter still overrides them within the +/// command's documented range. The defaults are the production ones; a host may lower them (the +/// tests do, so hostile input is refused quickly). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// `mesh.tet.generate`'s `max_steiner`. + pub max_steiner: usize, + /// `mesh.tet.generate`'s `max_tets`. + pub max_tets: usize, +} + +impl Default for Limits { + fn default() -> Self { + let cdt = modelcraft_mesh::cdt::CdtOptions::default(); + Limits { max_steiner: cdt.max_steiner, max_tets: cdt.max_tets } + } +} + /// The model being edited, the geometry it is built on and the commands that built it. #[derive(Debug, Clone, Default)] pub struct Session { @@ -153,6 +172,8 @@ pub struct Session { results: BTreeMap)>, /// What the results display shows (`results.*`); `None` shows the model. pub results_view: Option, + /// The default work caps of commands; a file replacing the session keeps them. + pub limits: Limits, } impl Session { @@ -400,16 +421,20 @@ mod tests { json!({"geometry": {"body": "Bar"}, "elements": [1, 99], "faces": [{"element": 1, "face": 200}]}), json!({"geometry": {"body": "Bar", "vertex": "v"}, "nodes": [1], "edges": [{"element": 1, "edge": 0}]}), ]; + // Small work caps, so input that would mesh up to the production caps (needle triangles + // need Steiner points all along them) is refused at once. The caps themselves are what + // `tet_generate_honours_the_session_caps` checks. + let capped = || Session { limits: Limits { max_steiner: 64, max_tets: 100_000 }, ..Session::default() }; for spec in commands() { for p in &hostile { - let mut s = Session::default(); + let mut s = capped(); let _ = s.execute(spec.id, &p.clone()); } } - assert!(Session::default().execute("no.such", &json!({})).is_err()); + assert!(capped().execute("no.such", &json!({})).is_err()); // The same against a defined model, so edits and deletes reach existing definitions. - let mut defined = Session::default(); + let mut defined = capped(); crate::cmd::define::tests::defined_bar(&mut defined); for spec in commands() { for p in &hostile { @@ -423,7 +448,7 @@ mod tests { // The same against an open design. let (_dir, path) = sample_design_file(&Plate::default()); - let mut opened = Session::default(); + let mut opened = capped(); opened.execute("geometry.open", &json!({"path": path})).unwrap(); let tess = [ json!({"body": "Body1"}), diff --git a/crates/lbm/tests/verification.rs b/crates/lbm/tests/verification.rs index 03404bf..0ccc72b 100644 --- a/crates/lbm/tests/verification.rs +++ b/crates/lbm/tests/verification.rs @@ -191,6 +191,7 @@ fn pipe(k: usize) -> (f64, f64) { } #[test] +#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn pipe_poiseuille_on_a_staircase_wall_converges() { let runs: Vec<(f64, f64)> = [4, 8, 16].iter().map(|&k| pipe(k)).collect(); let l2: Vec = runs.iter().map(|r| r.0).collect(); @@ -261,6 +262,7 @@ fn sphere_drag(r: f64, l: usize, steps: u64) -> (f64, f64) { } #[test] +#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn stokes_drag_of_a_sphere_array_matches_hasimoto() { let coarse = sphere_drag(3.0, 16, 1500); let fine = sphere_drag(6.0, 32, 3000); @@ -272,6 +274,7 @@ fn stokes_drag_of_a_sphere_array_matches_hasimoto() { } #[test] +#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn channel_with_a_parabolic_inlet_and_a_pressure_outlet_develops_poiseuille_flow() { // Plates 1 apart, 6 long; inlet peak 0.1; outlet at gauge pressure 0. let (h, l, nu, umax, n) = (1.0, 3.0, 0.05, 0.1, 16); diff --git a/crates/mesh/src/predicates/tests.rs b/crates/mesh/src/predicates/tests.rs index 697aafc..166ff4e 100644 --- a/crates/mesh/src/predicates/tests.rs +++ b/crates/mesh/src/predicates/tests.rs @@ -652,6 +652,7 @@ fn lnc_points_lie_on_their_segment_in_parameter_order() { } #[test] +#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn generic_orient3d_and_insphere_match_rationals_on_lnc_points() { let mut r = Rng(12); let mut checked = 0; diff --git a/crates/solver/tests/verification.rs b/crates/solver/tests/verification.rs index bb2ca1e..7f53709 100644 --- a/crates/solver/tests/verification.rs +++ b/crates/solver/tests/verification.rs @@ -151,6 +151,7 @@ fn timoshenko() -> f64 { } #[test] +#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn cantilever_tip_deflection_and_convergence() { let exact = timoshenko(); let euler_bernoulli = 4.0; diff --git a/crates/testkit/src/corpus.rs b/crates/testkit/src/corpus.rs index 729b6d3..304a1bf 100644 --- a/crates/testkit/src/corpus.rs +++ b/crates/testkit/src/corpus.rs @@ -253,6 +253,7 @@ mod tests { /// Every committed recipe builds without a failed feature, and its volume matches the closed /// form where it states one. #[test] + #[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] fn every_recipe_builds() { let parts = read_recipes(&corpus_dir()).unwrap(); assert!(parts.len() >= 15, "{}", parts.len()); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 64c0613..fcaee0d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -30,15 +30,17 @@ commands: licences [--check] write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate); --check fails when it is out of date - test [CARGO ARGS] [-- TEST ARGS] - build the tests with the ci profile, then run every test binary at once - (and the doc tests alongside) + test [--heavy] [CARGO ARGS] [-- TEST ARGS] + build the tests with the ci profile, then run every test binary at once; + --heavy runs only the ignored tests instead (heavy verification and corpus + tests, and the benchmarks) and the doc tests alongside: the CI heavy job corpus [--part NAME] [--no-write] build every meshing test corpus part (tests/corpus, analytic shapes), tessellate and mesh it, and write the results to docs/meshing/corpus.md (release build) wasm cargo check every library crate for wasm32-unknown-unknown ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests - with the wasm check alongside (stops at the first failing step) + (not the heavy ones) with the wasm check alongside (stops at the first failing + step) "; fn main() -> ExitCode { @@ -51,8 +53,17 @@ fn main() -> ExitCode { Some("app-icon") => app_icon::run(&root()), Some("licences") => licences::run(&root(), rest.contains(&"--check")), Some("test") => { - let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..])); - test::run(cargo_args, test_args, vec![test::doc_tests(cargo_args, test_args)]) + let (heavy, rest) = match rest.split_first() { + Some((&"--heavy", after)) => (true, after), + _ => (false, &rest[..]), + }; + let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((rest, &[][..]), |i| (&rest[..i], &rest[i + 1..])); + if heavy { + let ignored: Vec<&str> = ["--ignored"].into_iter().chain(test_args.iter().copied()).collect(); + test::run(cargo_args, &ignored, vec![test::doc_tests(cargo_args, test_args)]) + } else { + test::run(cargo_args, test_args, vec![]) + } } Some("ci") => cmd_ci(), Some("corpus") => { @@ -159,8 +170,9 @@ fn wasm_check() -> Result<(Command, usize), String> { Ok((c, set.len())) } -/// The quick gates first, so they fail fast; the wasm check (and doc tests) run while the test -/// binaries do, which leaves cargo's build lock free. +/// The quick gates first, so they fail fast; the wasm check runs while the test binaries do, +/// which leaves cargo's build lock free. The heavy tests and the doc tests are the CI heavy job's +/// (`cargo xtask test --heavy`). fn cmd_ci() -> Result<(), String> { let mut c = cargo(); c.args(["fmt", "--all", "--", "--check"]); @@ -172,7 +184,7 @@ fn cmd_ci() -> Result<(), String> { c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]); run(c, "cargo clippy -D warnings")?; let (wasm, n) = wasm_check()?; - test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm), test::doc_tests(&[], &[])])?; + test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)])?; eprintln!("ci: all gates passed"); Ok(()) } diff --git a/xtask/src/test.rs b/xtask/src/test.rs index 799c820..c560707 100644 --- a/xtask/src/test.rs +++ b/xtask/src/test.rs @@ -1,6 +1,6 @@ //! `cargo xtask test`: build the workspace's tests once, then run every test binary at the same //! time. `cargo test` runs them one after another, so one slow binary held up all the others; -//! the doc tests and any other `side` jobs run alongside on their own thread. +//! `side` jobs (the wasm check, the doc tests) run alongside on their own thread. use std::process::{Command, Stdio}; use std::sync::mpsc;