Test parts in CI
Write a test that loads a part and states what it must be, read a pass, a failure and a refusal in the run, and run it on every push.
Turn a part into a test suite: load the exported STEP or GLB, state what it must be, and read the outcome from the run. A failed or refused claim fails its test, so the run reports it.
npm install --save-dev geospec@betapip install geospec-engine pytest1. Write the test
geospec run discovers *.geospec.ts files under the directory it runs in and supplies the engine, so a spec imports no engine and has no hooks; loadModel reads source relative to that directory. A test that returns has passed.
import { describe, expectGeo, it } from 'geospec';
import { loadModel } from 'geospec/model';
describe('plate', () => {
it('is 40 x 40 x 20 with a 10 mm bore', async () => {
const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' });
expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } });
expectGeo(plate).toHaveCircularHole({ diameter: 10 });
});
it('has no 12 mm bore', async () => {
const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' });
expectGeo(plate).toHaveCircularHole({ diameter: 12 });
});
it('refuses a hole claim on the gear mesh', async () => {
const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' });
expectGeo(gear).toHaveCircularHole({ diameter: 4 });
});
});The pytest plugin gives each test an engine rooted at pytest's rootpath. expect_geo returns a report on a pass and raises Geo otherwise, so a test that returns has passed; a millimetre GLB declares source_unit="mm".
from geospec import expect_geo, load_model
def test_plate_size_and_bore():
plate = load_model(file="plate-with-through-bore.step", format="step")
expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20})
expect_geo(plate).to_have_circular_hole(diameter=10)
def test_plate_has_no_12_mm_bore():
plate = load_model(file="plate-with-through-bore.step", format="step")
expect_geo(plate).to_have_circular_hole(diameter=12)
def test_gear_mesh_refuses_a_hole_claim():
gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm")
expect_geo(gear).to_have_circular_hole(diameter=4)Expected output
The plate has a 10 mm bore, so the second test fails; the gear is a triangle mesh with no hole evidence, so the third is refused. The runner prints each message under its test.
$ npx geospec run .
FAIL plate.geospec.ts
fail plate > has no 12 mm bore
GEOSPEC_FEATURE_MISMATCH: No circular hole matches diameter 12 within 0.02 mm.
fail plate > refuses a hole claim on the gear mesh
GEOSPEC_UNSUPPORTED_EVIDENCE: expectGeo(...).toHaveCircularHole() needs exact BRep circular-hole evidence, which this subject does not carry.
1 passed, 1 failed, 3 selectedThe exit code is 1. The refused test is inconclusive: it is in neither count on the last line, and a run with one never succeeds.
$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
_________________________ test_plate_has_no_12_mm_bore _________________________
test_plate.py:12: in test_plate_has_no_12_mm_bore
expect_geo(plate).to_have_circular_hole(diameter=12)
.venv/lib/python3.13/site-packages/geospec/_api.py:742: in assertion
raise GeoSpecAssertionError.from_report(report)
E geospec._api.GeoSpecAssertionError: No circular hole matches diameter 12 within 0.02 mm.
_____________________ test_gear_mesh_refuses_a_hole_claim ______________________
test_plate.py:17: in test_gear_mesh_refuses_a_hole_claim
expect_geo(gear).to_have_circular_hole(diameter=4)
.venv/lib/python3.13/site-packages/geospec/_api.py:742: in assertion
raise GeoSpecAssertionError.from_report(report)
E geospec._api.GeoSpecAssertionError: expectGeo(...).toHaveCircularHole() needs exact BRep circular-hole evidence, which this subject does not carry.
=========================== short test summary info ============================
FAILED test_plate.py::test_plate_has_no_12_mm_bore - geospec._api.GeoSpecAsse...
FAILED test_plate.py::test_gear_mesh_refuses_a_hole_claim - geospec._api.GeoS...
2 failed, 1 passed in 0.08sA failure names what was measured; a refusal names the evidence the subject lacks. Fix the model for the first and the subject for the second: admit the gear's STEP export, or claim what a mesh can answer.
2. Read the status, then the code
A refusal never counts as a pass. The report's status tells a refusal from a failure, and the first diagnostic's code names the reason.
--json prints the report in place of the lines above; pipe it to jq to read it here, and keep the CI step unpiped so its exit code stands. Its accounting counts the refused test as inconclusive, and each test entry carries its status, its diagnostics and its claim reports, canonical bytes included. No rewrite turns the third test green under geospec run: give the claim the STEP export, or claim what the mesh answers and report the requirement as unsupported.
$ npx geospec run . --json | jq .accounting
{
"requestedFiles": [
"plate.geospec.ts"
],
"completedFiles": [
"plate.geospec.ts"
],
"notRunFiles": [],
"discoveryComplete": true,
"discovered": 3,
"selected": 3,
"completed": 3,
"passed": 1,
"failed": 1,
"unsupported": 0,
"inconclusive": 1,
"skipped": 0,
"notRun": 0,
"cancelled": false,
"bailed": false
}Rewritten this way, the third test passes and the run reports one failure, the 12 mm bore. With pytest and Geo imported:
def test_gear_mesh_refuses_a_hole_claim():
gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm")
with pytest.raises(GeoSpecAssertionError) as raised:
expect_geo(gear).to_have_circular_hole(diameter=4)
assert raised.value.result["status"] == "refused"
assert raised.value.code == "GEOSPEC_UNSUPPORTED_EVIDENCE"3. Run it in CI
The step's exit code is the verdict: 0 only when every selected test passed.
on: [push, pull_request]
jobs:
geospec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx geospec run .Wheels cover CPython 3.13 and 3.14 on manylinux_2_28 x86_64 and aarch64 and on macOS arm64.
on: [push, pull_request]
jobs:
geospec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- run: pip install geospec-engine pytest
- run: pytest -q --tb=shortVariations
Vitest instead of geospec run. geospec/vitest registers the matchers on Vitest's expect, over an engine the file creates: npm install --save-dev vitest@4 geospec@beta geospec-engine. A matcher returns a promise, so await it. A failed or refused claim fails the test with the claim's result as its message, and a refusal fails under .not too.
import { readFile } from 'node:fs/promises';
import { afterAll, describe, expect, it } from 'vitest';
import { createModelLoader } from 'geospec/model';
import { setupGeoSpecVitest } from 'geospec/vitest';
import { Engine } from 'geospec-engine/node';
const engine = new Engine();
const loadModel = createModelLoader({
engine,
readSource: async (source) => new Uint8Array(await readFile(source as string)),
});
setupGeoSpecVitest({ engine });
afterAll(async () => {
await loadModel.dispose();
engine.close();
});
describe('plate', () => {
it('is 40 x 40 x 20 with a 10 mm bore', async () => {
const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' });
await expect(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } });
await expect(plate).toHaveCircularHole({ diameter: 10 });
});
it('has no 12 mm bore', async () => {
const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' });
await expect(plate).toHaveCircularHole({ diameter: 12 });
});
it('refuses a hole claim on the gear mesh', async () => {
const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' });
await expect(gear).toHaveCircularHole({ diameter: 4 });
});
});The result JSON after each Error: and the diff Vitest prints under it are cut below.
$ npx vitest run
❯ plate.test.ts (3 tests | 2 failed) 58ms
× has no 12 mm bore 23ms
× refuses a hole claim on the gear mesh 7ms
FAIL plate.test.ts > plate > has no 12 mm bore
Error: {"claimId":"geospec-claim-3","diagnostics":[{"code":"GEOSPEC_FEATURE_MISMATCH",…
FAIL plate.test.ts > plate > refuses a hole claim on the gear mesh
Error: {"claimId":"geospec-claim-4","diagnostics":[{"code":"GEOSPEC_UNSUPPORTED_EVIDENCE",…
Test Files 1 failed (1)
Tests 2 failed | 1 passed (3)pytest is the runner; the wheel's plugin supplies the engine and needs no adapter.
A budget for the suite. TypeScript sets workUnitLimit per chain or per loaded model, and the host limits where it constructs the Engine. Python overrides the plugin's geospec_ fixture in conftest.py to build Geo. Set tolerances and budgets explains budgets and limits.