effectful 0.2.2

Effect<A, E, R> (sync + async), context/layers, pipe — interpreter-style, no bundled executor
Documentation
# Property Testing — Invariants over Inputs

Property tests check invariants over many generated inputs. Effect programs are good targets because the runner boundary is explicit and test environments are ordinary values.

## Setup

```toml
[dev-dependencies]
proptest = "1"
```

## Testing Pure Effects

```rust,ignore
use effectful::{Exit, run_test};
use proptest::prelude::*;

proptest! {
    #[test]
    fn addition_is_commutative(a: i64, b: i64) {
        let r_ab = run_test(add(a, b), ());
        let r_ba = run_test(add(b, a), ());

        prop_assert_eq!(r_ab, r_ba);
    }
}
```

`run_test(effect, env)` returns `Exit<A, E>`. For properties, either compare exits directly or match `Exit::Success(value)`.

## Testing Schema Round-Trips

Schemas expose `encode`, `decode`, and `decode_unknown`.

```rust,ignore
use effectful::schema::{i64, string, struct_, transform};
use proptest::prelude::*;

proptest! {
    #[test]
    fn user_schema_round_trips(name in "[a-zA-Z]{1,50}", age in 0i64..=120) {
        let schema = transform(
            struct_("name", string::<()>(), "age", i64::<()>()),
            |(name, age)| Ok(User { name, age }),
            |user: User| (user.name, user.age),
        );

        let original = User { name, age };
        let wire = schema.encode(original.clone());
        let parsed = schema.decode(wire);

        prop_assert_eq!(parsed.ok(), Some(original));
    }
}
```

Round-trip tests catch asymmetries between encode and decode logic.

## Testing Error Invariants

Use `TRef::make` and `commit` to construct transactional state.

```rust,ignore
use effectful::{Cause, Exit, TRef, commit, run_blocking, run_test};
use proptest::prelude::*;

proptest! {
    #[test]
    fn withdraw_never_goes_negative(balance in 0u64..=1_000_000, amount in 0u64..=1_000_000) {
        let account = run_blocking(commit(TRef::make(balance)), ()).expect("make account");
        let exit = run_test(withdraw(account.clone(), amount), ());
        let new_balance = run_blocking(commit(account.read_stm::<InsufficientFunds>()), ())
            .expect("read balance");

        if amount <= balance {
            prop_assert!(matches!(exit, Exit::Success(_)));
            prop_assert_eq!(new_balance, balance - amount);
        } else {
            prop_assert!(matches!(exit, Exit::Failure(Cause::Fail(InsufficientFunds))));
            prop_assert_eq!(new_balance, balance);
        }
    }
}
```

## Generating Service Environments

For integration-style properties, generate random state and place it in a test service.

```rust,ignore
proptest! {
    #[test]
    fn get_user_returns_what_was_saved(user in arbitrary_user()) {
        let db = Db::in_memory();
        let env = db.clone().to_context();

        let save_exit = run_test(save_user(user.clone()), env.clone());
        prop_assert!(matches!(save_exit, Exit::Success(_)));

        let get_exit = run_test(get_user(user.id), env);
        prop_assert!(matches!(get_exit, Exit::Success(retrieved) if retrieved == user));
    }
}
```

Define generators with normal `proptest` strategies.

```rust,ignore
fn arbitrary_user() -> impl Strategy<Value = User> {
    (
        any::<u64>().prop_map(UserId::new),
        "[a-zA-Z ]{1,50}",
        0i64..=120,
    ).prop_map(|(id, name, age)| User { id, name, age })
}
```

## Schema-Driven Generation

There is no built-in `generate_valid::<T>()` helper. Keep generators beside schemas and use round-trip properties to ensure they stay aligned.

```rust,ignore
proptest! {
    #[test]
    fn generated_users_are_accepted(user in arbitrary_user()) {
        let schema = user_schema();
        let wire = schema.encode(user.clone());
        prop_assert_eq!(schema.decode(wire).ok(), Some(user));
    }
}
```

## Shrinking

`proptest` automatically shrinks failing inputs to the smallest example that still fails. Because effects are run explicitly, shrinking remains easy to reason about.

```text
Test failed. Minimal failing input:
  name = ""
  age = -1
Reason: must not be empty (path: name)
```