# Tutorial

Grow one spec over five steps and read a pass, a failure, a refusal, a negation and a reproducible result.

Canonical page: https://www.geospec.xyz/docs/tutorial

Build one spec for a 10 × 20 × 30 mm STEP box, one claim at a time. Each step runs in the page; the **Check** line names the verdict to look for. The spec closes the page as a test file.

## Step 1: Load the box and claim its size [#step-1-load-the-box-and-claim-its-size]

`toHaveBoundingBox` compares the measured bounds with the size you state, at a tolerance of 0.02 mm.

**Python**

Python spells each matcher in snake case: `to_have_bounding_box`.

```ts
expectGeo(part)
  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });
```

**Check**: `passed`. The row's measured evidence lists `size` as `[10, 20, 30]`.

## Step 2: Add the claim that fails [#step-2-add-the-claim-that-fails]

A second claim expects a width of 12; the box is 10 wide. The demo shows every verdict, while a script throws at the first failure.

```ts
expectGeo(part)
  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });
expectGeo(part)
  .toHaveBoundingBox({ size: { x: 12 } });
```

**Check**: the first claim `passed`, the second `failed` with `GEOSPEC_BOUNDING_BOX_MISMATCH`, "Bounding box is off the declared bounds on size.x (tolerance 0.02)"; `details.axisFailures` lists expected 12, actual 10. Set `x` back to 10 and both pass.

## Step 3: Ask for a hole [#step-3-ask-for-a-hole]

A hole claim needs BRep evidence. The gear is a GLB triangle mesh, which carries none, so the engine refuses instead of guessing. Switch to the STEP box and the engine answers: a failure, because the box has no hole. On the STEP plate the 10 mm bore matches.

```ts
expectGeo(part)
  .toHaveCircularHole({ diameter: 10 });
```

**Check**: `refused` with `GEOSPEC_UNSUPPORTED_EVIDENCE` on the gear, `failed` with `GEOSPEC_FEATURE_MISMATCH` on the box, `passed` on the plate. A refusal is neither a pass nor a fail; [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) says what to change.

## Step 4: Negate a claim [#step-4-negate-a-claim]

`.not` asks the engine to show that a claim does not hold. The box does measure 10 × 20 × 30, so the first negated claim fails; it is not 12 wide, so the second passes. A refusal stays refused under `.not`.

**Python**

Python spells negation `.not_`.

```ts
expectGeo(part).not
  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });
expectGeo(part).not
  .toHaveBoundingBox({ size: { x: 12 } });
```

**Check**: the first claim `failed` with `GEOSPEC_NEGATED_MATCH`, the second `passed`.

## Step 5: Run it twice [#step-5-run-it-twice]

The run history under this demo records the SHA-256 of each response. Run the spec twice: the same model bytes, claims and engine version give the same response. Change `z` and the digest changes with the verdict; change it back and the first returns.

```ts
expectGeo(part)
  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });
```

**Check**: two history rows with one digest. A script hashes `report.canonicalResult` (Python `canonical_result_bytes`); the claim's automatic id is inside, so repeats in one process differ. [Reproduce a result](https://www.geospec.xyz/docs/guides/reproduce-a-result.mdx) says what to keep.

## The spec as a test file [#the-spec-as-a-test-file]

**TypeScript**

Steps 1 to 4 under Node's test runner, over the native addon. A failed or refused claim throws `GeoSpecAssertionError`, so a test asserts on its `report.status`. Save the file next to the [box](/demo/box-10x20x30.step) and the [gear](/demo/involute-gear.glb) and run `node --test box.test.ts`.

```ts title="box.test.ts"
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { after, test } from 'node:test';
import { expectGeo } from 'geospec';
import { GeoSpecAssertionError } from 'geospec/assertion-client';
import { createModelLoader } from 'geospec/model';
import { Engine } from 'geospec-engine/node';

const engine = new Engine();
const loadModel = createModelLoader({
  engine,
  readSource: async (source) => new Uint8Array(await readFile(source as string)),
});
after(async () => {
  await loadModel.dispose();
  engine.close();
});

test('measures 10 x 20 x 30', async () => {
  const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' });
  expectGeo(box).toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });
});

test('fails a width of 12', async () => {
  const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' });
  assert.throws(
    () => expectGeo(box).toHaveBoundingBox({ size: { x: 12 } }),
    (error: unknown) => error instanceof GeoSpecAssertionError && error.report.status === 'failed',
  );
});

test('refuses a hole on the gear mesh', async () => {
  const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' });
  assert.throws(
    () => expectGeo(gear).toHaveCircularHole({ diameter: 10 }),
    (error: unknown) => error instanceof GeoSpecAssertionError && error.report.status === 'refused',
  );
});

test('is not 12 wide', async () => {
  const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' });
  expectGeo(box).not.toHaveBoundingBox({ size: { x: 12 } });
});
```

```console
✔ measures 10 x 20 x 30 (29.585018ms)
✔ fails a width of 12 (4.525565ms)
✔ refuses a hole on the gear mesh (2.982111ms)
✔ is not 12 wide (2.510139ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 317.170889
```

**Python**

Steps 1 to 4 under pytest, whose plugin makes an engine per test and resolves `file` under the rootpath. A failed or refused claim raises `GeoSpecAssertionError`; a test asserts on `result["status"]`. Save the file next to the [box](/demo/box-10x20x30.step) and the [gear](/demo/involute-gear.glb) and run `pytest -q test_box.py`.

```python title="test_box.py"
import pytest
from geospec import GeoSpecAssertionError, expect_geo, load_model


def test_measures_10_x_20_x_30():
    box = load_model(file="box-10x20x30.step", format="step")
    expect_geo(box).to_have_bounding_box(size={"x": 10, "y": 20, "z": 30})


def test_fails_a_width_of_12():
    box = load_model(file="box-10x20x30.step", format="step")
    with pytest.raises(GeoSpecAssertionError) as failed:
        expect_geo(box).to_have_bounding_box(size={"x": 12})
    assert failed.value.result["status"] == "failed"


def test_refuses_a_hole_on_the_gear_mesh():
    gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm")
    with pytest.raises(GeoSpecAssertionError) as refused:
        expect_geo(gear).to_have_circular_hole(diameter=10)
    assert refused.value.result["status"] == "refused"


def test_is_not_12_wide():
    box = load_model(file="box-10x20x30.step", format="step")
    expect_geo(box).not_.to_have_bounding_box(size={"x": 12})
```

```console
....                                                                     [100%]
4 passed in 0.03s
```