forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
13 files changed
+113
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 <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:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -126,10 +126,9 @@ fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOptions> {
|
||||
}
|
||||
|
||||
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String, (u64, Arc<StepResult>)>,
|
||||
/// What the results display shows (`results.*`); `None` shows the model.
|
||||
pub results_view: Option<results::ResultsView>,
|
||||
/// 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"}),
|
||||
|
||||
@@ -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<f64> = 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
+21
-9
@@ -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(())
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user