Fast PR CI: small hostile-test caps, heavy tests in a separate job (#43)

* test: hostile-params test runs under small session work caps

Session gains Limits, the defaults of mesh.tet.generate's max_steiner and
max_tets (production values unchanged; a parameter still overrides them;
file.open keeps them). The registry-wide hostile-params test runs on
sessions with small caps, so the needle-triangle tessellation it meshes is
refused at once instead of meshing to the 50k Steiner cap: the engine test
binary drops from ~350 s to ~20 s locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* ci: move heavy tests to a heavy job on main, nightly and on demand

Four slow tests are #[ignore = "heavy: ..."] (every corpus recipe, the
cantilever convergence study, the explicit-vs-modal cantilever period, the
generic-predicate rational cross-check); fast representative tests of each
area stay in the PR gate. cargo xtask test --heavy runs every ignored test
(benchmarks included) and the doc tests; cargo xtask test and ci run the
rest without the doc tests. The new heavy CI job runs it on pushes to main,
nightly and on workflow_dispatch. Branch jobs restore main's rust cache and
no longer save their own. AGENTS.md documents the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* test: the three slow LBM verification cases run in the heavy job

The Stokes-drag sphere array, the staircase-pipe convergence study and the
developing-channel case are heavy (21 s, 11 s, 6 s alone); plane
Poiseuille, Couette, Taylor-Green, reproducibility and the stability
limits stay in the PR gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 21:21:49 -04:00
1 parent 7dc7a0e36d
commit 5e5a2ae251
13 files changed
+113 -18

No files matched your search

+21
View File
@@ -4,6 +4,9 @@ on:
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
schedule:
# Nightly: the heavy tests on main.
- cron: '23 3 * * *'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -65,8 +68,26 @@ jobs:
components: rustfmt, clippy components: rustfmt, clippy
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - 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 - 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: advisories:
name: Dependency advisories and sources name: Dependency advisories and sources
needs: changes needs: changes
+8
View File
@@ -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 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 --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. 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 <branch>`). 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: - **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 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 that updates it** (never merge `main` into a branch), push, open a PR, let CI run. A PR merges
+1
View File
@@ -13,6 +13,7 @@ geometry boundary (SolidCraft's kernel and tessellator) are in place; meshing is
```sh ```sh
cargo xtask ci # fmt, assets, licences, layers, clippy, tests, wasm 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 -- commands # every command and its parameters
cargo run -p modelcraft-cli -- run script.json # run a command script cargo run -p modelcraft-cli -- run script.json # run a command script
``` ```
+3 -1
View File
@@ -154,9 +154,11 @@ impl Session {
(session, warnings) (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) { fn replace_with(&mut self, mut opened: Session) {
opened.history = std::mem::take(&mut self.history); opened.history = std::mem::take(&mut self.history);
opened.limits = self.limits;
opened.clear_history(); opened.clear_history();
*self = opened; *self = opened;
} }
+22 -3
View File
@@ -126,10 +126,9 @@ fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOptions> {
} }
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> { fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let defaults = CdtOptions::default();
let cdt_options = CdtOptions { let cdt_options = CdtOptions {
max_steiner: optional_count(p, "max_steiner", defaults.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_steiner: optional_count(p, "max_steiner", s.limits.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_tets: optional_count(p, "max_tets", s.limits.max_tets as u64, 1..=TET_MAX_TETS)?,
}; };
let (surface, body, face_names) = { let (surface, body, face_names) = {
let Input { mesh, body, faces } = surface_input(s, p)?; 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"); 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] #[test]
fn tet_generate_on_an_imported_surface_and_an_open_one() { fn tet_generate_on_an_imported_surface_and_an_open_one() {
use std::sync::Arc; use std::sync::Arc;
+1
View File
@@ -792,6 +792,7 @@ pub(crate) mod tests {
/// bending energy, so without hourglass stiffness (viscous control) the period is longer by /// 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. /// (1 − 1/n²)^{−½} (3.3% for n = 4); the stiffness form restores part of it.
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn explicit_cantilever_period_matches_the_modal_frequency() { fn explicit_cantilever_period_matches_the_modal_frequency() {
let (length, n) = (10.0, [40usize, 4usize]); let (length, n) = (10.0, [40usize, 4usize]);
let mut s = Session::default(); let mut s = Session::default();
+29 -4
View File
@@ -124,6 +124,25 @@ pub(crate) fn next_revision() -> u64 {
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) 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. /// The model being edited, the geometry it is built on and the commands that built it.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct Session { pub struct Session {
@@ -153,6 +172,8 @@ pub struct Session {
results: BTreeMap<String, (u64, Arc<StepResult>)>, results: BTreeMap<String, (u64, Arc<StepResult>)>,
/// What the results display shows (`results.*`); `None` shows the model. /// What the results display shows (`results.*`); `None` shows the model.
pub results_view: Option<results::ResultsView>, pub results_view: Option<results::ResultsView>,
/// The default work caps of commands; a file replacing the session keeps them.
pub limits: Limits,
} }
impl Session { impl Session {
@@ -400,16 +421,20 @@ mod tests {
json!({"geometry": {"body": "Bar"}, "elements": [1, 99], "faces": [{"element": 1, "face": 200}]}), 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}]}), 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 spec in commands() {
for p in &hostile { for p in &hostile {
let mut s = Session::default(); let mut s = capped();
let _ = s.execute(spec.id, &p.clone()); 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. // 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); crate::cmd::define::tests::defined_bar(&mut defined);
for spec in commands() { for spec in commands() {
for p in &hostile { for p in &hostile {
@@ -423,7 +448,7 @@ mod tests {
// The same against an open design. // The same against an open design.
let (_dir, path) = sample_design_file(&Plate::default()); 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(); opened.execute("geometry.open", &json!({"path": path})).unwrap();
let tess = [ let tess = [
json!({"body": "Body1"}), json!({"body": "Body1"}),
+3
View File
@@ -191,6 +191,7 @@ fn pipe(k: usize) -> (f64, f64) {
} }
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn pipe_poiseuille_on_a_staircase_wall_converges() { fn pipe_poiseuille_on_a_staircase_wall_converges() {
let runs: Vec<(f64, f64)> = [4, 8, 16].iter().map(|&k| pipe(k)).collect(); let runs: Vec<(f64, f64)> = [4, 8, 16].iter().map(|&k| pipe(k)).collect();
let l2: Vec<f64> = runs.iter().map(|r| r.0).collect(); let l2: Vec<f64> = runs.iter().map(|r| r.0).collect();
@@ -261,6 +262,7 @@ fn sphere_drag(r: f64, l: usize, steps: u64) -> (f64, f64) {
} }
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn stokes_drag_of_a_sphere_array_matches_hasimoto() { fn stokes_drag_of_a_sphere_array_matches_hasimoto() {
let coarse = sphere_drag(3.0, 16, 1500); let coarse = sphere_drag(3.0, 16, 1500);
let fine = sphere_drag(6.0, 32, 3000); let fine = sphere_drag(6.0, 32, 3000);
@@ -272,6 +274,7 @@ fn stokes_drag_of_a_sphere_array_matches_hasimoto() {
} }
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn channel_with_a_parabolic_inlet_and_a_pressure_outlet_develops_poiseuille_flow() { 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. // 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); let (h, l, nu, umax, n) = (1.0, 3.0, 0.05, 0.1, 16);
+1
View File
@@ -652,6 +652,7 @@ fn lnc_points_lie_on_their_segment_in_parameter_order() {
} }
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn generic_orient3d_and_insphere_match_rationals_on_lnc_points() { fn generic_orient3d_and_insphere_match_rationals_on_lnc_points() {
let mut r = Rng(12); let mut r = Rng(12);
let mut checked = 0; let mut checked = 0;
+1
View File
@@ -151,6 +151,7 @@ fn timoshenko() -> f64 {
} }
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn cantilever_tip_deflection_and_convergence() { fn cantilever_tip_deflection_and_convergence() {
let exact = timoshenko(); let exact = timoshenko();
let euler_bernoulli = 4.0; let euler_bernoulli = 4.0;
+1
View File
@@ -253,6 +253,7 @@ mod tests {
/// Every committed recipe builds without a failed feature, and its volume matches the closed /// Every committed recipe builds without a failed feature, and its volume matches the closed
/// form where it states one. /// form where it states one.
#[test] #[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn every_recipe_builds() { fn every_recipe_builds() {
let parts = read_recipes(&corpus_dir()).unwrap(); let parts = read_recipes(&corpus_dir()).unwrap();
assert!(parts.len() >= 15, "{}", parts.len()); assert!(parts.len() >= 15, "{}", parts.len());
+21 -9
View File
@@ -30,15 +30,17 @@ commands:
licences [--check] licences [--check]
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate); write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate);
--check fails when it is out of date --check fails when it is out of date
test [CARGO ARGS] [-- TEST ARGS] test [--heavy] [CARGO ARGS] [-- TEST ARGS]
build the tests with the ci profile, then run every test binary at once build the tests with the ci profile, then run every test binary at once;
(and the doc tests alongside) --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] corpus [--part NAME] [--no-write]
build every meshing test corpus part (tests/corpus, analytic shapes), tessellate 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) and mesh it, and write the results to docs/meshing/corpus.md (release build)
wasm cargo check every library crate for wasm32-unknown-unknown wasm cargo check every library crate for wasm32-unknown-unknown
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests 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 { fn main() -> ExitCode {
@@ -51,8 +53,17 @@ fn main() -> ExitCode {
Some("app-icon") => app_icon::run(&root()), Some("app-icon") => app_icon::run(&root()),
Some("licences") => licences::run(&root(), rest.contains(&"--check")), Some("licences") => licences::run(&root(), rest.contains(&"--check")),
Some("test") => { Some("test") => {
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..])); let (heavy, rest) = match rest.split_first() {
test::run(cargo_args, test_args, vec![test::doc_tests(cargo_args, test_args)]) 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("ci") => cmd_ci(),
Some("corpus") => { Some("corpus") => {
@@ -159,8 +170,9 @@ fn wasm_check() -> Result<(Command, usize), String> {
Ok((c, set.len())) Ok((c, set.len()))
} }
/// The quick gates first, so they fail fast; the wasm check (and doc tests) run while the test /// The quick gates first, so they fail fast; the wasm check runs while the test binaries do,
/// binaries do, which leaves cargo's build lock free. /// 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> { fn cmd_ci() -> Result<(), String> {
let mut c = cargo(); let mut c = cargo();
c.args(["fmt", "--all", "--", "--check"]); c.args(["fmt", "--all", "--", "--check"]);
@@ -172,7 +184,7 @@ fn cmd_ci() -> Result<(), String> {
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]); c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
run(c, "cargo clippy -D warnings")?; run(c, "cargo clippy -D warnings")?;
let (wasm, n) = wasm_check()?; 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"); eprintln!("ci: all gates passed");
Ok(()) Ok(())
} }
+1 -1
View File
@@ -1,6 +1,6 @@
//! `cargo xtask test`: build the workspace's tests once, then run every test binary at the same //! `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; //! 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::process::{Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;