# `@test` Decorator + RunContext + SceneHandle API

Authoritative sources: `core/decorator.py`, `runner/test_context.py`,
`runner/scene_helpers.py`. The repo's `docs/kit-tests/writing-tests.md` is
the longest-form reference for test authors.

For task-oriented guidance, refer to `docs/writing-tests.md`.

## `simready_benchmark.test`

```python
def test(
    features:        list[dict[str, str]],            # REQUIRED: [{"id": str, "version": str}, ...]
    requirement:     str | None,                       # DEPRECATED: ignored by matching, rollup, and reporting (feature-based); kept for authoring continuity
    name:            str,                              # REQUIRED
    description:     str,                              # REQUIRED, non-empty after .strip()
    expected_video:  str,                              # REQUIRED, non-empty after .strip()
    version:         str,                              # REQUIRED, semver
    engine:          dict[str, Any] | None = None,     # {"tags": [...], "version": "..."}
    config_defaults: dict[str, Any] | None = None,
    max_duration:    int = 300,                        # seconds; runner kills tests that exceed this
    enabled:         bool = True,                      # False = no-op (still validates required kwargs)
) -> Callable:
```

The decorated function MUST be `async def` and accept a single `RunContext`
argument. Both constraints raise `TypeError` if violated.

## `simready_benchmark.RunContext`

The runtime handle passed into every test.

### Properties

```python
ctx.asset_path           # str: absolute path to the asset USD
ctx.asset_rel_path       # str: relative to the asset root
ctx.output_dir           # str: per-test output directory
ctx.test_name            # str
ctx.test_version         # str
ctx.feature_id           # str
ctx.requirement_id       # str
ctx.engine_name          # str: for example, "kit"
ctx.engine_version       # str
ctx.session_id           # str
ctx.scene                # SceneHandle: refer to the section below
ctx.config               # dict: config_defaults merged with per-asset overrides
ctx.failed               # bool
ctx.failure_message      # Optional[str]
ctx.failure_details      # Optional[dict]
ctx.skipped              # bool
ctx.skip_reason          # Optional[str]
ctx.warnings             # list[str]
ctx.metrics              # dict[name -> {"value", "unit"}]
ctx.steps                # list of step dicts emitted by step()
ctx.media                # list of media-registry entries
```

### Progress and Logging

```python
ctx.step(message: str, progress: float | None = None, detail: str | None = None) -> None
ctx.log(message: str) -> None
ctx.warn(message: str) -> None
```

Each also emits a liveness signal. Call `ctx.step()` between phases longer than 30 seconds.

### Result Control

```python
ctx.fail(message: str, details: dict | None = None) -> None
ctx.skip(reason: str) -> None
ctx.precheck_failure(message: str) -> None    # raises PrecheckAborted; chooses fail/skip by validation claim
```

`fail()` and `skip()` set the flag but do NOT stop execution. Follow with
`return` to exit the test early. `precheck_failure()` raises and unwinds the
test function automatically.

### Metrics

```python
ctx.add_metric(name: str, value: Any, unit: str | None = None) -> None
```

### Frame Capture and Video (All Async Unless Noted)

```python
path: str | None = await ctx.capture_frame(
    camera=None,             # camera prim path or None for default
    label=None,              # filename prefix ("foo" -> "foo_001.png"); None uses global counter
    stabilize_frames=2,      # number of app updates to wait for RTX convergence
    role="normal",           # "summary" | "error" | "worst" | "normal"
)

video: str | None = ctx.encode_video(    # sync
    frames: list[str],
    fps: int = 30,
    delete_frames: bool = True,
    label: str | None = None,
    role: str = "normal",
)

ctx.attach_media(path: str, role: str = "normal") -> None  # sync; register an externally-produced file
```

### Rendering / Settle

```python
await ctx.settle(frames=None, count=1)         # render N frames * count for RTX convergence
ctx.set_settle_frames(frames: int) -> None      # default frame count for settle()
await ctx.step_one()                            # one physics tick + one render frame, no settle
```

### Physics

```python
await ctx.physics_step()
await ctx.physics_advance()
await ctx.physics_steps(count: int)
ctx.configure_physics(deterministic=True, physics_fps=240.0, gravity=-9.81) -> None
```

### State Queries (Sync)

```python
bounds = ctx.get_asset_bounds()      # object with .min, .max (3-tuples)
pos    = ctx.get_asset_position()    # (x, y, z) world position
vel    = ctx.get_asset_velocity()    # (x, y, z) velocity
```

### Image Comparison (Sync)

```python
result = ctx.compare_images(path_a, path_b, threshold=0.03)
result = ctx.compare_object_pixels(path_a, path_b, background_color, ...)
result = ctx.compare_image_sequences(frames_a, frames_b, mode="consecutive", ...)
visible = ctx.check_object_visible(image_path, expected_color, min_pixels=100)
```

### Framing Helpers

```python
await ctx.ensure_object_framed(diagnostic_color=(0.9, 0.2, 0.0), padding=1.2, max_attempts=5)
```

### Camera Follow

```python
ctx.setup_camera_follow(config=None) -> None
ctx.update_camera_follow(config=None, update_history=True) -> None
```

## `simready_benchmark.SceneHandle`

Engine-agnostic scene API. `ctx.scene` is a `SceneHandle` implementation
(Kit, NVCF, and so on). All listed methods are abstract. The engine plugin
implements them.

### Asset Loading

```python
handle = ctx.scene.load_asset(asset_path, position=None, rotation=None,
                              parent_xform="/World/AssetRoot", timeout=30)
ctx.scene.load_asset_at_height(asset_path, height_multiplier=1.0)
```

### Room

```python
room = ctx.scene.add_room()                            # construct room handle
room.auto_size(handle)                                  # size to fit the asset
room.set_color(r, g, b)
room.hide_ground()
room.show_ground(color=(0.5, 0.5, 0.5))
room.place_asset_above_ground(height_factor=4.0)
room.place_asset_at_ground(margin=0.1)
room.add_slope(angle=45.0, friction=0.5)
room.place_asset_on_slope()

ctx.scene.setup_room(size=(x, y, z), color=(r, g, b))   # equivalent low-level path
ctx.scene.auto_size_room(bbox_multiplier=10.0, snap=100.0)
ctx.scene.enable_ground_plane(friction=0.5, position=None)
ctx.scene.disable_ground_plane()
ctx.scene.add_inclined_plane(...)
```

### Physics

```python
physics = ctx.scene.add_physics(gravity=9.81, fps=240.0)
physics.play()
physics.stop()
physics.set_gravity(9.81)
physics.set_fps(240.0)
physics.set_deterministic(True)
physics.remove()
```

### Materials

```python
snapshot = ctx.scene.apply_diagnostic_material(color=(0.9, 0.2, 0.0))
snapshot = ctx.scene.apply_flat_color(prim_path, color=(0.9, 0.2, 0.0))
ctx.scene.restore_materials(snapshot)
ctx.scene.replace_material(prim_path, material)
```

### Visibility

```python
ctx.scene.hide_asset()
ctx.scene.show_asset()
ctx.scene.set_prim_visibility(prim_path, visible: bool)
ctx.scene.hide_room()
ctx.scene.show_room()
```

### Camera

```python
ctx.scene.set_camera(position: tuple, target: tuple)
ctx.scene.auto_frame_camera(padding=1.2)
ctx.scene.setup_camera_follow(config=None)
ctx.scene.update_camera_follow(config=None, update_history=True)
```

### Lighting

```python
dome = ctx.scene.lighting.add_dome(intensity=1000.0)
sun  = ctx.scene.lighting.add_directional(direction=(45.0, 0.0, 90.0), intensity=3000.0)
sun.set_direction(x, y, z)
sun.set_intensity(3000.0)
sun.remove()
ctx.scene.lighting.clear()
```

### Transforms

```python
ctx.scene.rotate_asset("X", 45.0)
ctx.scene.rotate_asset_multi([("X", 45.0), ("Z", 90.0)])
ctx.scene.reset_asset_transform()
ctx.scene.set_transform(prim_path, position=(x,y,z), rotation=(rx,ry,rz))
```

### Queries

```python
prim  = ctx.scene.get_prim(prim_path)
prims = ctx.scene.find_prims(type_name="Mesh", name_pattern="Gripper_*")
ctx.scene.asset           # the current asset handle (read-only property)
ctx.scene.set_double_sided(True)
```

### Reset

```python
ctx.scene.clear()
ctx.scene.reset_room()
```

## Kit-Specific Helpers (`simready_benchmark_engine_kit`)

When the active engine is `kit`, additional modules ship inside `simready_benchmark_engine_kit`
for finer control:

- `simready_benchmark_engine_kit.camera_follow`: `setup_camera_follow`, `update_camera_follow`, `SlidingBBoxWindow`, `CameraMotionOptions`.
- `simready_benchmark_engine_kit.force_display`: `draw_force_arrow`, `clear_visuals`, `FORCE_DIRECTIONS`.
- `simready_benchmark_engine_kit.text_3d`: `create_3d_text`, `compute_screen_position`, `update_3d_text_transform`.
- `simready_benchmark_engine_kit.viewport_tools`: `add_viewport_text`, `remove_viewport_text`, `clear_all_viewport_text`.
- `simready_benchmark_engine_kit.physics_utils`: `check_physics_ready`, `find_or_create_physics_scene`, `configure_physx_determinism`, `pin_body_to_world`.

These import directly. The high-level `ctx.scene.*` API is the recommended
entry point unless you need a helper it does not expose.
