diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6f8b29a..d9ad042 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ target/ .hegel/ *.class + +# implementation-loop hook state files +.claude/review-complete +.claude/improvements-complete + +# editor / IDE / OS artifacts +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index e854dde..c79cf05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,10 @@ authoritative references are hegel-rust (the engine and canonical client) and it ## Build & test -- `just coverage` / `mvn verify` — runs all tests and enforces **100% instruction + branch - coverage** (JaCoCo). This is a hard gate. -- `just conformance` — the behaviour suite against the real engine. +- `just coverage` / `mvn clean verify` — runs all tests and enforces **100% instruction + branch + coverage** (JaCoCo). This is a hard gate. `GeneratorBehaviourTest` exercises every generator and + combinator against the real engine (there is no separate "conformance" suite — with the in-process + libhegel engine there is no protocol to conform to, only ordinary integration testing). - `just build-libhegel` — build `libhegel` from a sibling `../hegel-rust` checkout. - `just format` / `just lint` — google-java-format via fmt-maven-plugin. @@ -38,13 +39,24 @@ Layers (all in package `dev.hegel`): - **Generators** — `Generator` (public) with `map`/`filter`/`flatMap`. `BasicGenerator` (schema + parse) and the `MaybeBasic` marker drive the basic/composite dual path: `map` on a basic generator composes the parse over the same schema (one engine call); otherwise it falls - back to a span. `Generators` is the factory facade. Collection/`oneOf`/tuple generators choose - the basic schema path when their elements are basic and the engine collection API otherwise. + back to a span. `Generators` is the factory facade. Collection/`oneOf`/tuple/`fixedDict`/`arrays` + generators choose the basic schema path when their elements are basic and the engine collection + API otherwise. `Gen.Deferred` (`deferred`) is a memoised, intentionally non-basic forward + reference for recursive generators. `bigIntegers` sends bignum bounds and `Cbor` decodes CBOR + tag 2/3 bignums; `durations`/`localDates`/`localTimes`/`localDateTimes`/`instants` map engine + integers/format strings to `java.time`. - **Public API** — `Hegel.check` / `Hegel.with`, `TestCase` (`draw`/`assume`/`note`/`target`), the `@HegelTest` annotation + `HegelTestExtension` (a JUnit 5 `TestTemplateInvocationContextProvider` that drives the engine loop and invokes the user method per case). -- **Derivation** — `Derive` + `RecordGenerator` build generators from records, enums, scalars, and - generic `List`/`Set`/`Optional`/`Map` by reflection. +- **Stateful testing** — `StateMachine` (`rules`/`invariants`) + `Rule` + `Stateful.run`, a + client-side driver over the span/`draw` primitives (no engine support; mirrors hegel-go's + `RunStateful`). Value pools (`Variables`) are deferred until libhegel exposes a pool C ABI. +- **Explicit examples** — `Settings.example(Map)` registers label→value examples; `Runner` replays + the body against an explicit-mode `TestCase` (draws resolve by label, no engine) before the + generation loop, gated on the `EXPLICIT` phase. +- **Derivation** — `Derive` + `RecordGenerator` build generators from records, enums, scalars, + arrays, sealed interfaces (a `oneOf` over permitted subclasses), `java.time` types, and generic + `List`/`Set`/`Optional`/`Map` by reflection. ## Coverage notes diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 8a18150..e11323d 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -85,9 +85,11 @@ n = 50; ## Use generators `dev.hegel.Generators` provides a rich set of generators. Primitives include `integers`, -`longs`, `floats`, `booleans`, `text`, and `binary`; collections include `lists`, `sets`, and -`maps`; and there are `tuples`, `oneOf`, `optional`, `sampledFrom`, `just`, plus format -generators (`emails`, `urls`, `ipv4`, `dates`, `fromRegex`, …). +`longs`, `bytes`, `shorts`, `bigIntegers`, `floats`, `floats32`, `booleans`, `text`, and `binary`; +collections include `lists`, `sets`, `maps`, `arrays`, and `fixedDict`; time values include +`durations`, `localDates`, `localTimes`, `localDateTimes`, and `instants`; and there are `tuples`, +`oneOf`, `optional`, `sampledFrom`, `just`, plus format generators (`emails`, `urls`, `ipv4`, +`dates`, `uuids`, `fromRegex`, …). For example, generate a list of integers: @@ -133,6 +135,13 @@ Build new generators from existing ones: tc.draw(integers()), tc.draw(integers()) }); ``` +- `deferred` lets a generator refer to itself, for recursive data: + ```java + Generator[] ref = new Generator[1]; + Generator node = Generators.deferred(() -> ref[0]); + ref[0] = Generators.compose(tc -> + new Node(tc.draw(integers()), tc.draw(optional(node)).orElse(null))); + ``` ## Control functions @@ -180,18 +189,76 @@ Hegel.with() .check(tc -> { /* ... */ }); ``` +## Explicit examples + +Sometimes you want to make sure a specific input is always tried (a past regression, a known edge +case). Register it with `example(Map.of(label, value))`; the body is replayed once per example with +those values substituted for its labelled draws, before the generation phase. Examples require +labelled draws: + +```java +Hegel.with() + .example(Map.of("x", 0)) + .example(Map.of("x", Integer.MAX_VALUE)) + .check(tc -> { + int x = tc.draw(integers(), "x"); + assertTrue(timesTwo(x) % 2 == 0); + }); +``` + +Explicit examples run as part of the `EXPLICIT` phase (enabled by default); disable them by leaving +`EXPLICIT` out of `phases(...)`. + +## Stateful (model-based) testing + +For testing a stateful system, describe it as a `StateMachine`: a list of `Rule`s (actions) and +optional `invariants` that must hold after every step. Hegel draws a sequence of rules, applies +them, and checks the invariants, shrinking any failing sequence to a minimal one. Hold the system's +state in the machine instance, created fresh per test case: + +```java +final class CounterModel implements StateMachine { + private int n = 0; + + @Override public List rules() { + return List.of( + Rule.of("increment", tc -> n++), + Rule.of("decrement", tc -> n--)); + } + + @Override public List invariants() { + return List.of(Rule.of("inRange", tc -> assertTrue(Math.abs(n) < 1_000_000))); + } +} + +@HegelTest +void counter(TestCase tc) { + Stateful.run(tc, new CounterModel()); +} +``` + +A rule that calls `tc.assume(...)` to reject the current state is skipped and another is drawn. +(Engine-managed value pools — `Variables` — are not yet supported.) + ## Deriving generators from types -Hegel can build a generator for a record, enum, or supported scalar/collection type by reflection: +Hegel can build a generator for many types by reflection: scalars and their wrappers, `String`, +`byte[]`, enums, records (recursively), arrays, `List`/`Set`/`Optional`/`Map`, `java.time` types +(`LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, `Duration`), and sealed interfaces (a choice +over their permitted subclasses): ```java record Point(int x, int y) {} enum Color { RED, GREEN, BLUE } +sealed interface Shape permits Circle, Square {} +record Circle(double radius) implements Shape {} +record Square(int side) implements Shape {} @HegelTest void derived(TestCase tc) { Point p = tc.draw(Generators.forType(Point.class)); Color c = tc.draw(Generators.forType(Color.class)); + Shape s = tc.draw(Generators.forType(Shape.class)); // a Circle or a Square // Override a single component: Point bounded = tc.draw(Generators.records(Point.class).with("x", integers(0, 9))); } diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..a3c0336 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,45 @@ +# hegel-java feature-parity plan + +Bring hegel-java up to feature parity with hegel-rust / hegel-go, **except** the stateful +`Variables`/value-pool feature, which is blocked on a new engine C ABI (the pool API is being added +to libhegel separately). + +Baseline before this work: 130 tests, 100% instruction+branch coverage, lint + docs all green. +After: still 100% coverage and green. (The generator integration tests formerly framed as a +"conformance" suite have been folded into `GeneratorBehaviourTest`; the in-process libhegel engine +has no protocol to conform to, so there is no separate conformance concept.) + +## Self-contained generator additions + +- [x] `fromRegex(pattern, fullmatch)` overload (schema `fullmatch` bool, default false) +- [x] Wider numerics: `bytes()`/`bytes(min,max)`, `shorts()`/`shorts(min,max)`, + `bigIntegers(min,max)` (+ CBOR tag 2/3 bignum decoding) +- [x] `float32`: `FloatGenerator.asFloat()` + `Generators.floats32()` +- [x] `durations()` / `durations(min,max)` +- [x] Native `java.time`: `localDates`/`localTimes`/`localDateTimes`/`instants` + derivation +- [x] `arrays(componentType, element, length)` + array-component derivation (variable length) +- [x] `fixedDict(Map>)` +- [x] `deferred(Supplier>)` forward-reference / recursive combinator + +## Derivation + +- [x] Sealed-interface derivation: `oneOf` over `getPermittedSubclasses()` + +## Larger features + +- [x] Tier-1 stateful testing: `StateMachine`/`Rule`/`Stateful.run` + (Variables/value-pool deliberately out of scope — blocked on engine pool API) +- [x] Explicit examples: `Settings.example(Map)` wired to the `EXPLICIT` phase + +## Cross-cutting + +- [x] README / GETTING_STARTED updated for new generators and stateful/explicit features +- [x] CLAUDE.md architecture notes updated + +## Deferred — blocked on the engine (not in scope for this branch) + +Stateful `Variables` value pools are **intentionally not implemented**: they require +`hegel_new_pool` / `hegel_pool_add` / `hegel_pool_generate` to be exported over the libhegel C ABI +(the engine's i128 pool/variable ids are being narrowed to `usize` first). The Tier-1 stateful +driver is built so this can be layered on once those hooks exist. This is the only known gap and it +is external to hegel-java. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..1e292ae --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,18 @@ +RELEASE_TYPE: minor + +Brings hegel-java to feature parity with hegel-rust and hegel-go, adding: + +- **Stateful (model-based) testing** — `StateMachine`, `Rule`, and `Stateful.run` drive a sequence + of rules with invariants checked after each step, shrinking failing sequences. (Engine-managed + value pools are not yet available.) +- **Explicit examples** — `Settings.example(Map)` replays the body with chosen values for its + labelled draws before generation, wired to the `EXPLICIT` phase. +- **New generators** — `bytes`, `shorts`, `bigIntegers`, single-precision `floats32` / + `FloatGenerator.asFloat()`, `durations`, native `java.time` (`localDates`, `localTimes`, + `localDateTimes`, `instants`), fixed-length `arrays`, `fixedDict`, and `deferred` for recursive + definitions. +- **`fromRegex(pattern, fullmatch)`** for whole-string matches. +- **Wider derivation** — `forType` now derives arrays, sealed interfaces (a choice over permitted + subclasses), and `java.time` types in addition to records, enums, scalars, and collections. + +Also fixes the CBOR decoder to handle arbitrary-precision integers beyond the `long` range. diff --git a/jqwik-comparison.md b/jqwik-comparison.md new file mode 100644 index 0000000..36e1203 --- /dev/null +++ b/jqwik-comparison.md @@ -0,0 +1,295 @@ +# hegel-java vs. jqwik: a porting-oriented comparison + +A feature comparison between [jqwik](https://jqwik.net/) and hegel-java, written to answer one +question: **if someone has an existing jqwik test suite, what does it take to move it to +hegel-java, and where will they get stuck?** + +The lens throughout is *common-case coverage* — what shows up in real jqwik suites, not an +exhaustive checklist of every annotation jqwik ships. Features are tagged: + +- **Deliberate difference** — hegel-java does the same job a different way. Not a gap to close in + the library; instead, the **porting skill** needs to know the translation. These are the bulk of + the work in any port, but they're mechanical. +- **Genuine gap** — a capability hegel-java doesn't have and arguably should. Tracked for later. +- **Not needed** — jqwik exposes a knob that exists only because jqwik (not an engine) owns + generation/shrinking. hegel-java's engine owns those decisions, so the knob has no analogue and + shouldn't grow one. + +The single genuine gap that matters for real suites is **statistics & coverage checking** +(§4). Everything else is either a deliberate API-shape difference with a clean translation, or a +small convenience. + +--- + +## 1. The core API-shape difference: declarative `@ForAll` vs. imperative `draw` + +**Deliberate difference.** This is the one structural change every ported test undergoes, so the +porting skill should treat it as the default transformation and apply it before worrying about +anything else. + +jqwik injects generated values as method parameters, configured by annotations: + +```java +@Property +void concatLength(@ForAll String a, @ForAll @Size(max = 10) List<@IntRange(min = 1) Integer> xs) { + assertThat(a).isNotNull(); + assertThat(xs.size()).isLessThanOrEqualTo(10); +} +``` + +hegel-java draws values imperatively from a `TestCase`: + +```java +@HegelTest +void concatLength(TestCase tc) { + String a = tc.draw(Generators.text()); + List xs = tc.draw(Generators.lists(Generators.integers(1, Integer.MAX_VALUE), 0, 10)); + assertThat(a).isNotNull(); + assertThat(xs.size()).isLessThanOrEqualTo(10); +} +``` + +### Translation rules for the porting skill + +Each `@ForAll`-annotated parameter becomes a `tc.draw(...)` statement at the top of the body, in +declaration order. Build the generator from the parameter's type and its constraint annotations: + +| jqwik parameter / annotation | hegel-java draw | +|---|---| +| `@ForAll int x` / `Integer` | `tc.draw(Generators.integers())` | +| `@ForAll @IntRange(min=a, max=b) int x` | `tc.draw(Generators.integers(a, b))` | +| `@ForAll long`, `@LongRange` | `Generators.longs(...)` | +| `@ForAll short` / `byte`, `@ShortRange` / `@ByteRange` | `Generators.shorts(...)` / `Generators.bytes(...)` | +| `@ForAll @Positive int` | `Generators.integers(1, Integer.MAX_VALUE)` | +| `@ForAll @Negative int` | `Generators.integers(Integer.MIN_VALUE, -1)` | +| `@ForAll double`, `@DoubleRange(min,max)` | `Generators.floats().min(a).max(b)` (`.excludeMin/.excludeMax` for exclusive bounds) | +| `@ForAll float` | `Generators.floats32()` | +| `@ForAll boolean` | `Generators.booleans()` | +| `@ForAll BigInteger`, `@BigRange` | `Generators.bigIntegers(min, max)` | +| `@ForAll String` | `Generators.text()` | +| `@ForAll @StringLength(min,max) String` | `Generators.text().minSize(min).maxSize(max)` | +| `@ForAll @AlphaChars String` | `Generators.text().categories("Lu", "Ll")` (or an explicit codepoint/char set) | +| `@ForAll @NumericChars String` | `Generators.text().categories("Nd")` | +| `@ForAll @CharRange(from,to) String` | `Generators.text().codepoints(from, to)` | +| `@ForAll @Chars({...}) String` | `Generators.text().includeCharacters("...")` with category exclusions, or build from `sampledFrom` | +| `@ForAll char` | `Generators.characters().map(s -> s.charAt(0))` | +| `@ForAll List`, `@Size(min,max)` | `Generators.lists(elem, min, max)` | +| `@ForAll Set` | `Generators.sets(elem, min, max)` | +| `@ForAll Map` | `Generators.maps(keys, values, min, max)` | +| `@ForAll T[]` | `Generators.arrays(T.class, elem, length)` (see §5: fixed length only) | +| `@ForAll Optional` | `Generators.optional(elem)` | +| `@ForAll @From("method") T` | inline the generator the `@Provide` method returns (see §3) | +| `@ForAll SomeRecord` / enum / sealed type | `Generators.forType(SomeRecord.class)` | + +Notes: + +- **`@WithNull(p)`** has no direct analogue (hegel generators don't inject `null`). Port to + `Generators.oneOf(Generators.just(null), gen)` when the test genuinely needs nulls, or drop it if + it was incidental. +- **`@NotBlank` / `@NotEmpty`** → add `.minSize(1)` (and a `.filter` for non-whitespace if the test + relies on it). +- **`@UniqueElements`** → `Generators.sets(...)` when element identity is the uniqueness key; + otherwise `.filter(...)` on a list. There's no feature-extractor form. +- **`@Scale`** applies to `BigDecimal`, which hegel-java doesn't generate — see §5. + +--- + +## 2. `@Property` settings → `@HegelTest` / `Settings` + +**Deliberate difference**, mostly a 1:1 mapping with a couple of "doesn't apply" cases. + +| jqwik `@Property(...)` | hegel-java | Notes | +|---|---|---| +| `tries = N` | `@HegelTest(testCases = N)` or `Settings.testCases(N)` | jqwik default 1000; hegel default 100 — bump on port if the suite relied on volume. | +| `seed = "..."` | `@HegelTest(seed = ...)` or `Settings.seed(...)` | | +| `maxDiscardRatio = N` | `Settings.suppressHealthCheck(HealthCheck.FILTER_TOO_MUCH)` to disable the guard | No exact ratio knob; the health check is the closest control. | +| `shrinking = OFF/BOUNDED/FULL` | `Settings.phases(...)` without `Phase.SHRINK` to disable | **Not needed** otherwise — engine owns shrinking. | +| `generation = RANDOMIZED` | default | | +| `generation = EXHAUSTIVE` | — | **Genuine gap** for the few tests that depend on exhaustiveness; re-express as ordinary randomized properties (see §6). | +| `generation = DATA_DRIVEN` | `Settings.example(...)` + `Phase.EXPLICIT` | See §3 (data-driven). | +| `edgeCases = MIXIN/FIRST/NONE` | — | **Not needed** — engine decides edge-case mixing. | +| `afterFailure = ...` | database replay (`Settings.database` / `noDatabase`) | hegel replays via its example DB / `Phase.REUSE`; no per-mode selector. | + +`@Example` (jqwik's `tries = 1` single run) ports to an ordinary JUnit test that calls +`Hegel.check(...)` once, or to a registered explicit example (§3). + +Organisational annotations — `@Label`, `@Tag`, `@Group`, `@Disabled` — are plain JUnit 5 features; +keep `@Tag`/`@Disabled` as-is and use JUnit's `@Nested` for `@Group`, `@DisplayName` for `@Label`. + +--- + +## 3. Generators / arbitraries and combinators + +hegel-java's `Generators` facade covers the common jqwik `Arbitraries` surface well. The combinator +set (`map`, `filter`, `flatMap`) matches jqwik's core, and `deferred(...)` covers +`Arbitraries.lazy`/`recursive`. Mapping the rest: + +| jqwik | hegel-java | Status | +|---|---|---| +| `Arbitraries.of(...)` | `Generators.sampledFrom(...)` | ✅ | +| `Arbitraries.just(v)` | `Generators.just(v)` | ✅ | +| `Arbitraries.oneOf(a, b, ...)` | `Generators.oneOf(a, b, ...)` | ✅ | +| `Arbitraries.frequency((w,v)...)` / `frequencyOf` | — | **Genuine gap** — no weighted choice (see §5). | +| `Arbitraries.integers().between(a,b)` etc. | `Generators.integers(a, b)` | ✅ | +| `.shrinkTowards(t)` | — | **Not needed** — engine owns shrink targets. | +| `.withDistribution(...)` / `withSizeDistribution` / `fixGenSize` | — | **Not needed** — engine owns distribution/size. | +| `Arbitraries.strings()....` | `Generators.text()...` | ✅ (rich Unicode category/codepoint control) | +| `Arbitraries.chars()` | `Generators.characters()` | ✅ (returns 1-char strings) | +| `Arbitraries.bigIntegers()` | `Generators.bigIntegers(min, max)` | ✅ (bounds required) | +| `Arbitraries.bigDecimals()` | — | **Genuine gap** (see §5). | +| `Arbitraries.maps(k, v)` | `Generators.maps(k, v[, min, max])` | ✅ | +| `Arbitrary.list()/set()/array()` | `Generators.lists/sets/arrays` | ✅ (array is fixed-length — §5) | +| `Arbitrary.stream()/iterator()` | — | minor gap; map a list to a stream/iterator. | +| `Arbitraries.combine(...).as(...)` | `Generators.compose(tc -> ...)` or nested `flatMap` | ✅ (imperative form) | +| `Builders.withBuilder(...)` | `Generators.compose(...)` | ✅ via imperative build | +| `Arbitraries.shuffle(...)` | — | minor gap; `compose` + manual shuffle, or `flatMap`. | +| `Arbitraries.randoms()` | — | minor gap; rarely used in properties. | +| `.injectNull(p)` / `.injectDuplicates(p)` | — | minor; `oneOf(just(null), gen)` for the former. | +| `.optional(p)` | `Generators.optional(gen)` | ✅ (no probability knob) | +| `Functions.function(...)` | — | **Genuine gap** (uncommon; see §5). | +| `Arbitraries.forType(...)` / `@UseType` | `Generators.forType(Class)` | ✅ (records, enums, sealed, java.time, collections) | + +### `@Provide` methods + +**Deliberate difference.** jqwik resolves `@ForAll("name")` to a `@Provide Arbitrary name()` +method by string. hegel-java has no such wiring: turn each `@Provide` method into a `Generator` +returned by an ordinary helper method or held in a field, and reference it directly in the +`tc.draw(...)` call. `@From("name")` and `@ForAll(supplier = X.class)` translate the same way — +inline the generator the provider/supplier produced. + +### Data-driven properties (`@FromData` + `Table.of`) + +**Deliberate difference**, but a clunky one. jqwik runs the body once per row of an explicit table. +hegel-java's analogue is `Settings.example(Map)` (replayed in `Phase.EXPLICIT`), +which is **label-keyed and one example per call** and requires the body to use *labelled* draws +(`tc.draw(gen, "label")`). Porting an N-row table means N `example(...)` registrations plus labelled +draws. For pure table-driven tests with no generation, a plain JUnit `@ParameterizedTest` is often +the cleaner destination — the porting skill should prefer that when the jqwik "property" was really +just a data table. + +--- + +## 4. Statistics & coverage — the one genuine feature gap that matters + +**Genuine gap. Flagged as a major feature for later.** + +jqwik's statistics API is widely used in real suites, and crucially it can carry *assertions*: a +property fails if a distribution isn't met. + +```java +@Property +void shapeDistribution(@ForAll("rectangles") Rectangle r) { + Statistics.label("orientation") + .collect(r.width() > r.height() ? "landscape" : "portrait"); + Statistics.coverage(c -> { + c.check("landscape").percentage(p -> p > 30.0); + c.check("portrait").percentage(p -> p > 30.0); + }); +} +``` + +hegel-java has **no equivalent** to: + +- `Statistics.collect(...)` / `Statistics.label(...)` — sample classification and tallying. +- `@StatisticsReport` / histogram output. +- `Statistics.coverage(...)` / `checkCoverage` — coverage as a *pass/fail condition*. + +What exists today is adjacent but not a substitute: `TestCase.note(...)` (a debug string on the +final replay only) and `TestCase.target(...)` (a hill-climbing score for the search, not a tally or +an assertion). Tests that *assert* coverage cannot be ported at all; tests that merely *report* +distributions lose their reporting. + +This is the recommended item to build for real feature parity. A minimal useful version is a +`TestCase.collect(label, value)` (or a `Statistics`-style facade) that tallies across the run plus a +post-run coverage assertion. Until then, the porting skill should flag any +`Statistics.collect`/`coverage` usage as **needs manual attention** rather than attempting a +mechanical translation. + +--- + +## 5. Smaller genuine gaps (low effort, occasionally hit) + +These come up often enough to note, but each is small: + +- **Weighted choice.** No `Arbitraries.frequency` / `frequencyOf`. `oneOf` is uniform only. Common + in jqwik suites that bias toward certain shapes. Worth adding a `Generators.frequency(...)`. +- **`BigDecimal`.** `bigIntegers` exists; `bigDecimals` does not, so `@Scale` and + `Arbitraries.bigDecimals()` have nothing to map to. +- **Variable-length arrays.** `Generators.arrays(Class, elem, length)` is **fixed-length only**. + jqwik's `Arbitrary.array(...)` is variable-length. (Derivation via `forType` does handle + variable-length arrays, so the capability exists in the engine — only the explicit factory is + fixed.) Port variable arrays via `lists(...).map(List::toArray)` for now. +- **`Functions.function(...)`** — generating deterministic functional-interface implementations. + Uncommon, but unportable when present. +- **`Stream` / `Iterator` element generation** — trivial to derive from a list; minor. +- **Direct sampling outside a property** (`Arbitrary.sample()` / `sampleStream()` / `JqwikSession`). + hegel-java's `Settings.singleTestCase(...)` probe is close but not a clean "give me one value / a + stream of values" API. Occasionally used for fixtures/exploration. + +--- + +## 6. Deliberately not needed (don't build these) + +These jqwik knobs exist because jqwik itself owns generation and shrinking. hegel-java delegates +those to the engine, so there's no place to hang them and adding them would fight the design: + +- `ShrinkingMode.OFF/BOUNDED/FULL`, `shrinkTowards` — engine owns shrinking. (`Phase.SHRINK` can be + toggled off wholesale if a test truly needs raw failures.) +- `EdgeCasesMode.MIXIN/FIRST/NONE` and custom edge-case configuration — engine owns edge cases. +- `withDistribution` / `withSizeDistribution` / `RandomDistribution` (gaussian/biased/uniform) / + `fixGenSize` — engine owns the distribution and adaptive sizing. +- `generation = EXHAUSTIVE` — the engine is randomized + coverage-guided, not exhaustive. The rare + test that depends on exhaustiveness (e.g. "check every value in a small enum") should be ported as + an ordinary property over `sampledFrom`/`forType` with enough `testCases` to cover the space, or + as a plain JUnit loop. This is the one "not needed" item with real porting consequences, so the + skill should call it out. + +--- + +## 7. Lifecycle + +**Mostly deliberate difference.** jqwik's container/property-level hooks map to standard JUnit 5: + +| jqwik | hegel-java (via JUnit 5) | +|---|---| +| `@BeforeContainer` / `@AfterContainer` | `@BeforeAll` / `@AfterAll` | +| `@BeforeProperty` / `@AfterProperty` | `@BeforeEach` / `@AfterEach` | +| `@BeforeTry` / `@AfterTry` | **no analogue** | + +The one real gap is **per-try** setup/teardown: jqwik runs `@BeforeTry`/`@AfterTry` around every +generated case, whereas hegel-java's `@BeforeEach`/`@AfterEach` run once per `@HegelTest` (the whole +property), not per case. Port per-try logic into the test body itself (run it at the top/bottom of +the `@HegelTest` method, which executes per case) — usually a clean translation, occasionally +awkward when the jqwik test reset field state via `@BeforeTry`. + +`PerProperty` / `AddLifecycleHook` / custom lifecycle hooks have no analogue and are rare in +ordinary suites; treat as needs-manual-attention if encountered. + +--- + +## 8. Stateful testing + +**Close match.** jqwik's `Action` / `ActionSequence` / invariant model maps cleanly onto +hegel-java's `StateMachine` (`rules()` + `invariants()`), `Rule.of(name, action)`, and +`Stateful.run(tc, machine)`. The one missing piece is jqwik-style typed **value pools** — hegel +defers `Variables` until libhegel exposes a pool ABI — so hold model state in the `StateMachine` +instance instead of in a pool. For the common case (a model object plus rules that mutate it), the +port is direct. + +--- + +## Summary + +For a typical jqwik suite, a port is **mostly mechanical**: rewrite `@ForAll` parameters as +`tc.draw(...)` (§1), map `@Property` settings (§2), inline `@Provide` generators (§3), and translate +lifecycle to JUnit (§7). The friction points that need human judgement, in rough order of how often +they bite: + +1. **Statistics / coverage assertions (§4)** — the only genuine feature gap of consequence; build + later, flag on port until then. +2. **Per-try lifecycle (§7)** — fold into the body. +3. **Data-driven tables (§3)** — often better as JUnit `@ParameterizedTest`. +4. **Weighted choice, `BigDecimal`, variable arrays (§5)** — small library additions. +5. **Exhaustive generation (§6)** — re-express as randomized. + +Everything else is a deliberate, mechanical translation that belongs in the porting skill. diff --git a/justfile b/justfile index 6423e1b..9d064dc 100644 --- a/justfile +++ b/justfile @@ -27,12 +27,9 @@ test: mvn -B test # Run tests with 100% coverage enforcement. Fails if coverage < 100%. +# Uses a clean build so stale jacoco.exec data cannot mask a coverage regression. coverage: - mvn -B verify - -# Run the conformance/behaviour suite against the real libhegel. -conformance: - mvn -B test -Dtest='*Conformance*,*Behaviour*' + mvn -B clean verify # Auto-format sources. format: diff --git a/pom.xml b/pom.xml index 13e6caa..8d2dc37 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,12 @@ prepare-agent + + + false + report diff --git a/src/main/java/dev/hegel/Cbor.java b/src/main/java/dev/hegel/Cbor.java index 35d9f8e..d3a40e3 100644 --- a/src/main/java/dev/hegel/Cbor.java +++ b/src/main/java/dev/hegel/Cbor.java @@ -36,10 +36,18 @@ static Object decode(byte[] bytes) { return convert(CBORObject.DecodeFromBytes(bytes)); } + /** CBOR tags for arbitrary-precision integers (RFC 8949 §3.4.3). */ + private static final int TAG_POSITIVE_BIGNUM = 2; + + private static final int TAG_NEGATIVE_BIGNUM = 3; + static Object convert(CBORObject o) { if (o.HasMostOuterTag(TAG_WTF8)) { return new String(o.Untag().GetByteString(), StandardCharsets.UTF_8); } + if (o.HasMostOuterTag(TAG_POSITIVE_BIGNUM) || o.HasMostOuterTag(TAG_NEGATIVE_BIGNUM)) { + return o.ToObject(BigInteger.class); + } switch (o.getType()) { case Integer: return o.ToObject(BigInteger.class); diff --git a/src/main/java/dev/hegel/Derive.java b/src/main/java/dev/hegel/Derive.java index 4d4fdea..31df7c6 100644 --- a/src/main/java/dev/hegel/Derive.java +++ b/src/main/java/dev/hegel/Derive.java @@ -37,12 +37,45 @@ private static Generator fromClass(Class cls) { if (cls.isRecord()) { return (Generator) (Generator) new RecordGenerator<>(cls, java.util.Map.of()); } + if (cls.isSealed()) { + return sealed(cls); + } + if (cls.isArray()) { + return arrayOf(cls.getComponentType()); + } throw new HegelException( "No default generator for " + cls.getName() + "; supply one explicitly (e.g. a Generator parameter or a record override)."); } + /** Derive a sum type as a choice over its permitted subclasses. */ + private static Generator sealed(Class cls) { + Class[] subs = cls.getPermittedSubclasses(); + @SuppressWarnings("unchecked") + Generator[] options = new Generator[subs.length]; + for (int i = 0; i < subs.length; i++) { + options[i] = fromType(subs[i]); + } + return Generators.oneOf(options); + } + + @SuppressWarnings("unchecked") + private static Generator arrayOf(Class componentType) { + Generator elem = fromType(componentType); + return (Generator) + (Generator) + Generators.lists(elem) + .map( + list -> { + Object arr = java.lang.reflect.Array.newInstance(componentType, list.size()); + for (int i = 0; i < list.size(); i++) { + java.lang.reflect.Array.set(arr, i, list.get(i)); + } + return arr; + }); + } + private static Generator scalar(Class cls) { if (cls == int.class || cls == Integer.class) { return Generators.integers(); @@ -62,6 +95,21 @@ private static Generator scalar(Class cls) { if (cls == byte[].class) { return Generators.binary(); } + if (cls == java.time.Duration.class) { + return Generators.durations(); + } + if (cls == java.time.LocalDate.class) { + return Generators.localDates(); + } + if (cls == java.time.LocalTime.class) { + return Generators.localTimes(); + } + if (cls == java.time.LocalDateTime.class) { + return Generators.localDateTimes(); + } + if (cls == java.time.Instant.class) { + return Generators.instants(); + } return null; } diff --git a/src/main/java/dev/hegel/FixedDictGenerator.java b/src/main/java/dev/hegel/FixedDictGenerator.java new file mode 100644 index 0000000..f5ee80c --- /dev/null +++ b/src/main/java/dev/hegel/FixedDictGenerator.java @@ -0,0 +1,67 @@ +package dev.hegel; + +import com.upokecenter.cbor.CBORObject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Generates a map with a fixed set of keys, each drawn from its own generator. Basic (one engine + * call) when every field generator is basic — the engine speaks a {@code tuple} of the field values + * in key order, reconstructed client-side into a map. Otherwise composite: each field is drawn in + * order inside a FIXED_DICT span. + */ +final class FixedDictGenerator + implements Generator>, MaybeBasic> { + private final List keys; + private final List> gens; + + FixedDictGenerator(Map> fields) { + this.keys = new ArrayList<>(fields.keySet()); + this.gens = new ArrayList<>(fields.values()); + } + + @Override + public BasicGenerator> asBasic() { + List> basics = new ArrayList<>(gens.size()); + CBORObject schemas = CBORObject.NewArray(); + for (Generator g : gens) { + BasicGenerator b = Gen.asBasic(g); + if (b == null) { + return null; + } + basics.add(b); + schemas.Add(b.schema); + } + CBORObject schema = CBORObject.NewMap().Add("type", "tuple").Add("elements", schemas); + return new BasicGenerator<>( + schema, + raw -> { + List rawList = Cbor.asList(raw); + Map out = new LinkedHashMap<>(); + for (int i = 0; i < keys.size(); i++) { + out.put(keys.get(i), basics.get(i).parseRaw(rawList.get(i))); + } + return out; + }); + } + + @Override + public Map generate(TestCase tc) { + BasicGenerator> basic = asBasic(); + if (basic != null) { + return basic.generate(tc); + } + tc.startSpan(Abi.LABEL_FIXED_DICT); + try { + Map out = new LinkedHashMap<>(); + for (int i = 0; i < keys.size(); i++) { + out.put(keys.get(i), gens.get(i).generate(tc)); + } + return out; + } finally { + tc.stopSpan(false); + } + } +} diff --git a/src/main/java/dev/hegel/FloatGenerator.java b/src/main/java/dev/hegel/FloatGenerator.java index 65bb35b..0c7971c 100644 --- a/src/main/java/dev/hegel/FloatGenerator.java +++ b/src/main/java/dev/hegel/FloatGenerator.java @@ -16,6 +16,7 @@ public final class FloatGenerator implements Generator, MaybeBasic, MaybeBasic, MaybeBasic asBasic() { .Add("exclude_max", excludeMax) .Add("allow_nan", an) .Add("allow_infinity", ai) - .Add("width", 64); + .Add("width", width); if (hasMin) { schema.Add("min_value", min); } @@ -133,4 +146,17 @@ public BasicGenerator asBasic() { public Double generate(TestCase tc) { return asBasic().generate(tc); } + + /** + * Produce single-precision {@code float} values instead of {@code double}, honouring all the + * bounds and special-value options configured so far. The engine generates values at 32-bit + * precision; the result is narrowed to {@code float}. + * + * @return a {@code float} generator + */ + public Generator asFloat() { + FloatGenerator single = + new FloatGenerator(min, max, allowNan, allowInfinity, excludeMin, excludeMax, 32); + return single.map(d -> (float) (double) d); + } } diff --git a/src/main/java/dev/hegel/Gen.java b/src/main/java/dev/hegel/Gen.java index 215b0ff..47eb98e 100644 --- a/src/main/java/dev/hegel/Gen.java +++ b/src/main/java/dev/hegel/Gen.java @@ -2,11 +2,34 @@ import java.util.function.Function; import java.util.function.Predicate; +import java.util.function.Supplier; /** Internal combinator implementations and the basicness dispatch helper. */ final class Gen { private Gen() {} + /** + * A lazily-resolved generator for forward references and recursion. It is intentionally not + * {@link MaybeBasic}: resolving eagerly would recurse forever, so a deferred generator always + * takes the composite path. + */ + static final class Deferred implements Generator { + private final Supplier> supplier; + private Generator resolved; + + Deferred(Supplier> supplier) { + this.supplier = supplier; + } + + @Override + public T generate(TestCase tc) { + if (resolved == null) { + resolved = supplier.get(); + } + return resolved.generate(tc); + } + } + /** Conventional retry limit before a filter rejects the whole case. */ static final int FILTER_RETRIES = 3; diff --git a/src/main/java/dev/hegel/Generators.java b/src/main/java/dev/hegel/Generators.java index cb0a3a3..1b2f943 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/src/main/java/dev/hegel/Generators.java @@ -1,6 +1,13 @@ package dev.hegel; import com.upokecenter.cbor.CBORObject; +import java.math.BigInteger; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -68,6 +75,67 @@ public static Generator longs(long min, long max) { return new BasicGenerator<>(schema, Cbor::asLong); } + /** + * Generates {@code byte} values across the full {@code byte} range. + * + * @return a byte generator + */ + public static Generator bytes() { + return bytes(Byte.MIN_VALUE, Byte.MAX_VALUE); + } + + /** + * Generates {@code byte} values in {@code [min, max]} (inclusive). For arbitrary-length byte + * sequences use {@link #binary()} instead. + * + * @param min lower bound (inclusive) + * @param max upper bound (inclusive) + * @return a byte generator + */ + public static Generator bytes(byte min, byte max) { + return integers(min, max).map(i -> (byte) (int) i); + } + + /** + * Generates {@code short} values across the full {@code short} range. + * + * @return a short generator + */ + public static Generator shorts() { + return shorts(Short.MIN_VALUE, Short.MAX_VALUE); + } + + /** + * Generates {@code short} values in {@code [min, max]} (inclusive). + * + * @param min lower bound (inclusive) + * @param max upper bound (inclusive) + * @return a short generator + */ + public static Generator shorts(short min, short max) { + return integers(min, max).map(i -> (short) (int) i); + } + + /** + * Generates {@link BigInteger} values in {@code [min, max]} (inclusive). Both bounds are + * required: the engine has no unbounded-integer schema. + * + * @param min lower bound (inclusive) + * @param max upper bound (inclusive) + * @return a big-integer generator + */ + public static Generator bigIntegers(BigInteger min, BigInteger max) { + if (min.compareTo(max) > 0) { + throw new IllegalArgumentException("bigIntegers: min (" + min + ") > max (" + max + ")"); + } + CBORObject schema = + CBORObject.NewMap() + .Add("type", "integer") + .Add("min_value", CBORObject.FromObject(min)) + .Add("max_value", CBORObject.FromObject(max)); + return new BasicGenerator<>(schema, Cbor::asBigInteger); + } + // --- floats --- /** @@ -79,6 +147,79 @@ public static FloatGenerator floats() { return new FloatGenerator(null, null, null, null, false, false); } + /** + * Generates single-precision {@code float} values across the default range. For finer control + * (bounds, NaN/infinity policy) configure {@link #floats()} and call {@link + * FloatGenerator#asFloat()}. + * + * @return a float generator + */ + public static Generator floats32() { + return floats().asFloat(); + } + + // --- time & duration values --- + + /** + * Generates non-negative {@link Duration} values up to {@code Long.MAX_VALUE} nanoseconds. + * + * @return a duration generator + */ + public static Generator durations() { + return durations(Duration.ZERO, Duration.ofNanos(Long.MAX_VALUE)); + } + + /** + * Generates {@link Duration} values in {@code [min, max]} (inclusive). The bounds must be + * representable in nanoseconds as a {@code long}. + * + * @param min lower bound (inclusive) + * @param max upper bound (inclusive) + * @return a duration generator + */ + public static Generator durations(Duration min, Duration max) { + if (min.compareTo(max) > 0) { + throw new IllegalArgumentException("durations: min (" + min + ") > max (" + max + ")"); + } + return longs(min.toNanos(), max.toNanos()).map(Duration::ofNanos); + } + + /** + * Generates {@link LocalDate} values (the engine's {@code YYYY-MM-DD} dates parsed natively). + * + * @return a local-date generator + */ + public static Generator localDates() { + return dates().map(LocalDate::parse); + } + + /** + * Generates {@link LocalTime} values (the engine's time-of-day strings parsed natively). + * + * @return a local-time generator + */ + public static Generator localTimes() { + return times().map(LocalTime::parse); + } + + /** + * Generates {@link LocalDateTime} values (the engine's ISO-8601 datetimes parsed natively). + * + * @return a local-date-time generator + */ + public static Generator localDateTimes() { + return datetimes().map(LocalDateTime::parse); + } + + /** + * Generates {@link Instant} values by interpreting the engine's ISO-8601 datetimes as UTC. + * + * @return an instant generator + */ + public static Generator instants() { + return datetimes().map(s -> LocalDateTime.parse(s).toInstant(ZoneOffset.UTC)); + } + // --- booleans --- /** @@ -300,6 +441,57 @@ public static Generator> tuples(Generator... generators) { return new TupleGenerator(List.of(generators)); } + /** + * Generates fixed-length arrays of {@code componentType} with elements drawn from {@code + * element}. For variable-length arrays of reference types use {@link #lists(Generator)} and + * convert, or derive them via {@link #forType(Class)}. + * + * @param componentType the array component type (a reference type) + * @param element the element generator + * @param length the exact array length + * @param the component type + * @return a fixed-length array generator + */ + public static Generator arrays( + Class componentType, Generator element, int length) { + return lists(element, length, length) + .map( + list -> { + @SuppressWarnings("unchecked") + T[] arr = (T[]) java.lang.reflect.Array.newInstance(componentType, list.size()); + return list.toArray(arr); + }); + } + + /** + * Generates a map with a fixed set of keys, each value drawn from its own generator. Iteration + * order follows {@code fields}; pass a {@link java.util.LinkedHashMap} for a predictable order. + * + * @param fields the generator for each key + * @return a fixed-key map generator + */ + public static Generator> fixedDict(Map> fields) { + return new FixedDictGenerator(fields); + } + + /** + * A lazily-resolved generator, for forward references and recursive definitions. The supplier is + * invoked on first use and the result memoised: + * + *
{@code
+   * Generator[] ref = new Generator[1];
+   * Generator node = deferred(() -> ref[0]);
+   * ref[0] = compose(tc -> new Node(tc.draw(integers()), tc.draw(optional(node)).orElse(null)));
+   * }
+ * + * @param supplier supplies the underlying generator on first draw + * @param the value type + * @return a deferred generator + */ + public static Generator deferred(java.util.function.Supplier> supplier) { + return new Gen.Deferred<>(supplier); + } + // --- imperative composition --- /** @@ -414,13 +606,29 @@ public static Generator datetimes() { } /** - * Generates strings matching a (Python-compatible) regular expression. + * Generates strings containing a match for a (Python-compatible) regular expression. * * @param pattern the regex pattern * @return a regex generator */ public static Generator fromRegex(String pattern) { - CBORObject schema = CBORObject.NewMap().Add("type", "regex").Add("pattern", pattern); + return fromRegex(pattern, false); + } + + /** + * Generates strings matching a (Python-compatible) regular expression. + * + * @param pattern the regex pattern + * @param fullmatch if {@code true}, the whole string must match; if {@code false}, the string + * need only contain a match + * @return a regex generator + */ + public static Generator fromRegex(String pattern, boolean fullmatch) { + CBORObject schema = + CBORObject.NewMap() + .Add("type", "regex") + .Add("pattern", pattern) + .Add("fullmatch", fullmatch); return new BasicGenerator<>(schema, Cbor::asString); } diff --git a/src/main/java/dev/hegel/Phase.java b/src/main/java/dev/hegel/Phase.java index 9be1729..76b14e2 100644 --- a/src/main/java/dev/hegel/Phase.java +++ b/src/main/java/dev/hegel/Phase.java @@ -8,7 +8,7 @@ * shrinking (useful to see an unshrunk failure quickly), and {@code phases()} runs nothing. */ public enum Phase { - /** Run hard-coded explicit examples (reserved for future use). */ + /** Run the explicit examples registered via {@link Settings#example} before generation. */ EXPLICIT(Abi.PHASE_EXPLICIT), /** Replay counterexamples persisted from previous runs (requires a database + key). */ REUSE(Abi.PHASE_REUSE), diff --git a/src/main/java/dev/hegel/Rule.java b/src/main/java/dev/hegel/Rule.java new file mode 100644 index 0000000..56b4929 --- /dev/null +++ b/src/main/java/dev/hegel/Rule.java @@ -0,0 +1,29 @@ +package dev.hegel; + +import java.util.function.Consumer; + +/** + * A named action (or invariant) of a {@link StateMachine}. A rule may draw values from and mutate + * the system under test; an invariant should only inspect it. A rule that calls {@link + * TestCase#assume} to reject the current state is skipped and another rule is tried. + */ +public final class Rule { + final String name; + final Consumer action; + + private Rule(String name, Consumer action) { + this.name = name; + this.action = action; + } + + /** + * Create a rule. + * + * @param name a label shown in the step trace of a failing run + * @param action the action to apply, given the current test case + * @return the rule + */ + public static Rule of(String name, Consumer action) { + return new Rule(name, action); + } +} diff --git a/src/main/java/dev/hegel/Runner.java b/src/main/java/dev/hegel/Runner.java index 931ad40..2eb30f5 100644 --- a/src/main/java/dev/hegel/Runner.java +++ b/src/main/java/dev/hegel/Runner.java @@ -29,6 +29,9 @@ static void run( Consumer body, Map env, PrintStream out) { + if (!settings.examples.isEmpty() && explicitPhaseOn(settings)) { + runExplicitExamples(settings, body); + } MemorySegment s = lib.settingsNew(); try { applySettings(lib, s, settings, env); @@ -109,6 +112,38 @@ static void driveOneCase( } } + static boolean explicitPhaseOn(Settings st) { + return st.phasesMask == null || (st.phasesMask & Abi.PHASE_EXPLICIT) != 0; + } + + static void runExplicitExamples(Settings st, Consumer body) { + for (Map example : st.examples) { + TestCase tc = new TestCase(example, false, null); + try { + body.accept(tc); + } catch (AssumeRejected e) { + // The example failed a precondition; skip it like any rejected case. + } catch (HegelException e) { + throw e; + } catch (Throwable e) { + throw explicitFailure(example, e); + } + } + } + + private static AssertionError explicitFailure(Map example, Throwable e) { + StringBuilder sb = new StringBuilder("Hegel explicit example failed:"); + for (Map.Entry entry : example.entrySet()) { + sb.append("\n ") + .append(entry.getKey()) + .append(" = ") + .append(TestCase.repr(entry.getValue())) + .append(";"); + } + sb.append("\n").append(describe(e)); + return new AssertionError(sb.toString()); + } + static void applySettings(Libhegel lib, MemorySegment s, Settings st, Map env) { boolean ci = Settings.isCi(env); lib.settingsTestCases(s, st.testCases); diff --git a/src/main/java/dev/hegel/Settings.java b/src/main/java/dev/hegel/Settings.java index a0d52b3..b6355e4 100644 --- a/src/main/java/dev/hegel/Settings.java +++ b/src/main/java/dev/hegel/Settings.java @@ -1,5 +1,7 @@ package dev.hegel; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -25,7 +27,19 @@ enum DbMode { private static final Settings DEFAULTS = new Settings( - 100, false, 0L, null, DbMode.DEFAULT, null, 0, null, Verbosity.NORMAL, false, null, null); + 100, + false, + 0L, + null, + DbMode.DEFAULT, + null, + 0, + null, + Verbosity.NORMAL, + false, + null, + null, + List.of()); final long testCases; final boolean hasSeed; @@ -39,6 +53,7 @@ enum DbMode { final boolean singleTestCase; final Boolean reportMultipleFailures; final String name; + final List> examples; private Settings( long testCases, @@ -52,7 +67,8 @@ private Settings( Verbosity verbosity, boolean singleTestCase, Boolean reportMultipleFailures, - String name) { + String name, + List> examples) { this.testCases = testCases; this.hasSeed = hasSeed; this.seed = seed; @@ -65,6 +81,7 @@ private Settings( this.singleTestCase = singleTestCase; this.reportMultipleFailures = reportMultipleFailures; this.name = name; + this.examples = examples; } /** @@ -88,7 +105,8 @@ private Settings copy( Verbosity verbosity, boolean singleTestCase, Boolean reportMultipleFailures, - String name) { + String name, + List> examples) { return new Settings( testCases, hasSeed, @@ -101,7 +119,8 @@ private Settings copy( verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -126,7 +145,8 @@ public Settings testCases(long n) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -148,7 +168,8 @@ public Settings seed(long seed) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -170,7 +191,8 @@ public Settings derandomize(boolean derandomize) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -192,7 +214,8 @@ public Settings database(String path) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -213,7 +236,8 @@ public Settings noDatabase() { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -239,7 +263,8 @@ public Settings suppressHealthCheck(HealthCheck... checks) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -266,7 +291,8 @@ public Settings phases(Phase... phases) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -288,7 +314,8 @@ public Settings verbosity(Verbosity verbosity) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); } /** @@ -310,7 +337,8 @@ public Settings singleTestCase(boolean single) { verbosity, single, reportMultipleFailures, - name); + name, + examples); } /** @@ -332,7 +360,8 @@ public Settings reportMultipleFailures(boolean yes) { verbosity, singleTestCase, yes, - name); + name, + examples); } /** @@ -354,7 +383,36 @@ public Settings name(String name) { verbosity, singleTestCase, reportMultipleFailures, - name); + name, + examples); + } + + /** + * Register an explicit example: a map of {@code draw} label to the value to supply for it. Before + * the generation phase (when the {@link Phase#EXPLICIT} phase is enabled, as it is by default), + * the body is run once per registered example with these values substituted for its labelled + * draws. The body must draw with labels (see {@link TestCase#draw(Generator, String)}). + * + * @param values the label-to-value map for one example + * @return a new settings instance + */ + public Settings example(Map values) { + List> next = new ArrayList<>(examples); + next.add(Map.copyOf(values)); + return copy( + testCases, + hasSeed, + seed, + derandomize, + dbMode, + dbPath, + suppressMask, + phasesMask, + verbosity, + singleTestCase, + reportMultipleFailures, + name, + List.copyOf(next)); } /** diff --git a/src/main/java/dev/hegel/StateMachine.java b/src/main/java/dev/hegel/StateMachine.java new file mode 100644 index 0000000..914367d --- /dev/null +++ b/src/main/java/dev/hegel/StateMachine.java @@ -0,0 +1,31 @@ +package dev.hegel; + +import java.util.List; + +/** + * A model for stateful (model-based) testing: a set of {@link Rule}s to apply in a generated order, + * and {@link #invariants()} checked after each successful step. Implement this over a fresh + * instance of the system under test per test case and drive it with {@link Stateful#run}: + * + *
{@code
+ * Hegel.check(tc -> Stateful.run(tc, new CounterModel()));
+ * }
+ */ +public interface StateMachine { + /** + * The rules (actions) that may be applied to the system under test. Must be non-empty. + * + * @return the rules + */ + List rules(); + + /** + * Invariants checked after each successful rule application (and once before any rule). The + * default is none. + * + * @return the invariants + */ + default List invariants() { + return List.of(); + } +} diff --git a/src/main/java/dev/hegel/Stateful.java b/src/main/java/dev/hegel/Stateful.java new file mode 100644 index 0000000..78f4781 --- /dev/null +++ b/src/main/java/dev/hegel/Stateful.java @@ -0,0 +1,87 @@ +package dev.hegel; + +import java.util.List; + +/** + * Drives a {@link StateMachine} as a property test, mirroring hegel-go's {@code RunStateful}. + * + *

It checks the invariants once, draws a step count, and for each step draws a rule, applies it + * inside a STATEFUL span, and re-checks the invariants. Rules that reject the current state via + * {@link TestCase#assume} are skipped and another rule is drawn, up to a retry budget. A failing + * rule or invariant fails the test, and the engine shrinks the sequence of choices to a minimal + * reproducing run. + * + *

Value pools ({@code Variables}) are not yet supported; hold the system's state in the {@link + * StateMachine} instance, created fresh per test case. + */ +public final class Stateful { + private Stateful() {} + + /** Maximum number of successful steps per test case. */ + static final int MAX_STEPS = 50; + + /** + * Run {@code machine} against the current test case. + * + * @param tc the current test case + * @param machine the model to drive (created fresh per test case) + */ + public static void run(TestCase tc, StateMachine machine) { + List rules = machine.rules(); + if (rules.isEmpty()) { + throw new IllegalArgumentException("state machine has no rules"); + } + List invariants = machine.invariants(); + + tc.note("Initial invariant check."); + for (Rule inv : invariants) { + callInvariant(tc, inv); + } + + int nSteps = tc.draw(Generators.integers(1, MAX_STEPS)); + int maxAttempts = nSteps * 10 + 100; // budget against rules that always reject + int succeeded = 0; + int attempts = 0; + int step = 0; + while (succeeded < nSteps && attempts < maxAttempts) { + step++; + attempts++; + int idx = rules.size() == 1 ? 0 : tc.draw(Generators.integers(0, rules.size() - 1)); + Rule rule = rules.get(idx); + tc.note("Step " + step + ": " + rule.name); + if (callRule(tc, rule)) { + succeeded++; + for (Rule inv : invariants) { + callInvariant(tc, inv); + } + } + } + } + + /** Run an invariant inside a STATEFUL span; failures propagate as test failures. */ + private static void callInvariant(TestCase tc, Rule inv) { + tc.startSpan(Abi.LABEL_STATEFUL); + try { + inv.action.accept(tc); + } finally { + tc.stopSpan(false); + } + } + + /** + * Run a rule inside a STATEFUL span, recovering an {@link TestCase#assume} rejection. + * + * @return {@code true} if the rule ran to completion, {@code false} if it rejected the state + */ + private static boolean callRule(TestCase tc, Rule rule) { + tc.startSpan(Abi.LABEL_STATEFUL); + try { + rule.action.accept(tc); + return true; + } catch (AssumeRejected e) { + return false; + } finally { + tc.stopSpan(false); + } + } +} diff --git a/src/main/java/dev/hegel/TestCase.java b/src/main/java/dev/hegel/TestCase.java index 5e28711..0e6aa9a 100644 --- a/src/main/java/dev/hegel/TestCase.java +++ b/src/main/java/dev/hegel/TestCase.java @@ -18,6 +18,7 @@ */ public final class TestCase { private final DataSource source; + private final Map explicit; // non-null => explicit-example replay mode private final boolean reporting; private final PrintStream out; private int drawDepth; @@ -25,6 +26,14 @@ public final class TestCase { TestCase(DataSource source, boolean reporting, PrintStream out) { this.source = source; + this.explicit = null; + this.reporting = reporting; + this.out = out; + } + + TestCase(Map explicit, boolean reporting, PrintStream out) { + this.source = null; + this.explicit = explicit; this.reporting = reporting; this.out = out; } @@ -49,6 +58,9 @@ public T draw(Generator generator) { * @return the generated value */ public T draw(Generator generator, String label) { + if (explicit != null) { + return drawExplicit(label); + } boolean top = drawDepth == 0; drawDepth++; T value; @@ -67,6 +79,18 @@ public T draw(Generator generator, String label) { return value; } + @SuppressWarnings("unchecked") + private T drawExplicit(String label) { + if (label == null) { + throw new HegelException( + "explicit examples require labelled draws: use draw(generator, label)"); + } + if (!explicit.containsKey(label)) { + throw new HegelException("explicit example has no value for label '" + label + "'"); + } + return (T) explicit.get(label); + } + /** * Reject the current test case unless {@code condition} holds. The engine discards it without * counting it against the test-case budget and tries another input. @@ -106,7 +130,9 @@ public void target(double value) { * @param label groups observations for multi-objective search */ public void target(double value, String label) { - source.target(value, label); + if (explicit == null) { + source.target(value, label); + } } // --- package-private primitives used by generators --- diff --git a/src/test/java/dev/hegel/Checks.java b/src/test/java/dev/hegel/Checks.java index 02d3d57..0c9ed87 100644 --- a/src/test/java/dev/hegel/Checks.java +++ b/src/test/java/dev/hegel/Checks.java @@ -4,7 +4,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; -/** Hegel-testing-Hegel utilities used by the conformance suite. */ +/** Hegel-testing-Hegel utilities used by the generator behaviour tests. */ final class Checks { private Checks() {} diff --git a/src/test/java/dev/hegel/DerivationTest.java b/src/test/java/dev/hegel/DerivationTest.java index a27d743..80af6f7 100644 --- a/src/test/java/dev/hegel/DerivationTest.java +++ b/src/test/java/dev/hegel/DerivationTest.java @@ -108,6 +108,43 @@ void unsupportedReflectiveTypeFails() { assertThrows(HegelException.class, () -> Derive.fromType(weird)); } + record Temporal( + java.time.LocalDate d, + java.time.LocalTime t, + java.time.LocalDateTime dt, + java.time.Instant i, + java.time.Duration dur) {} + + @Test + void derivesJavaTimeTypes() { + assertAllExamples( + forType(Temporal.class), + v -> v.d() != null && v.t() != null && v.dt() != null && v.i() != null && v.dur() != null); + } + + sealed interface Shape permits Circle, Rectangle {} + + record Circle(double radius) implements Shape {} + + record Rectangle(int w, int h) implements Shape {} + + @Test + void derivesSealedInterface() { + assertAllExamples(forType(Shape.class), s -> s instanceof Circle || s instanceof Rectangle); + } + + record WithArrays(int[] nums, String[] words) {} + + @Test + void derivesArrayComponents() { + assertAllExamples( + forType(WithArrays.class), + v -> + v.nums() != null + && v.words() != null + && java.util.Arrays.stream(v.words()).allMatch(w -> w != null)); + } + @Test void pointGeneratesIntegers() { Generator g = forType(Point.class); diff --git a/src/test/java/dev/hegel/ExplicitExampleTest.java b/src/test/java/dev/hegel/ExplicitExampleTest.java new file mode 100644 index 0000000..4f3debf --- /dev/null +++ b/src/test/java/dev/hegel/ExplicitExampleTest.java @@ -0,0 +1,116 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExplicitExampleTest { + + @Test + void passingExampleRuns() { + // Exercises a labelled draw, note, and target in explicit-replay mode; generation also passes. + Hegel.with() + .testCases(20) + .noDatabase() + .example(Map.of("x", 3)) + .check( + tc -> { + int x = tc.draw(integers(0, 10), "x"); + tc.note("x=" + x); + tc.target(x); + assertTrue(x >= 0); + }); + } + + @Test + void failingExampleIsReported() { + AssertionError err = + assertThrows( + AssertionError.class, + () -> + Hegel.with() + .testCases(20) + .noDatabase() + .example(Map.of("x", 999)) + .check( + tc -> { + int x = tc.draw(integers(0, 10), "x"); + assertTrue(x != 999, "boom"); + })); + assertTrue(err.getMessage().contains("explicit example")); + assertTrue(err.getMessage().contains("999")); + } + + @Test + void exampleSkippedWhenExplicitPhaseOff() { + // EXPLICIT not in the phase set: the failing example is not run, and generation never produces + // 999, so the run passes. + Hegel.with() + .testCases(20) + .noDatabase() + .phases(Phase.GENERATE) + .example(Map.of("x", 999)) + .check( + tc -> { + int x = tc.draw(integers(0, 10), "x"); + assertTrue(x != 999); + }); + } + + @Test + void exampleRunsWhenExplicitPhaseExplicitlyOn() { + assertThrows( + AssertionError.class, + () -> + Hegel.with() + .testCases(20) + .noDatabase() + .phases(Phase.EXPLICIT, Phase.GENERATE) + .example(Map.of("x", 999)) + .check( + tc -> { + int x = tc.draw(integers(0, 10), "x"); + assertTrue(x != 999); + })); + } + + @Test + void rejectedExampleIsSkipped() { + // The example value is rejected via assume, so it is skipped; generation rarely rejects. + Hegel.with() + .testCases(20) + .noDatabase() + .example(Map.of("x", 5)) + .check( + tc -> { + int x = tc.draw(integers(0, 10), "x"); + tc.assume(x != 5); + assertTrue(x >= 0); + }); + } + + @Test + void unlabeledDrawInExampleFails() { + assertThrows( + HegelException.class, + () -> + Hegel.with() + .noDatabase() + .example(Map.of("x", 1)) + .check(tc -> tc.draw(integers(0, 10)))); + } + + @Test + void missingLabelInExampleFails() { + assertThrows( + HegelException.class, + () -> + Hegel.with() + .noDatabase() + .example(Map.of("x", 1)) + .check(tc -> tc.draw(integers(0, 10), "y"))); + } +} diff --git a/src/test/java/dev/hegel/ConformanceTest.java b/src/test/java/dev/hegel/GeneratorBehaviourTest.java similarity index 66% rename from src/test/java/dev/hegel/ConformanceTest.java rename to src/test/java/dev/hegel/GeneratorBehaviourTest.java index b3e2ecf..e10339f 100644 --- a/src/test/java/dev/hegel/ConformanceTest.java +++ b/src/test/java/dev/hegel/GeneratorBehaviourTest.java @@ -2,15 +2,19 @@ import static dev.hegel.Checks.assertAllExamples; import static dev.hegel.Checks.minimal; +import static dev.hegel.Generators.bigIntegers; import static dev.hegel.Generators.binary; import static dev.hegel.Generators.booleans; +import static dev.hegel.Generators.bytes; import static dev.hegel.Generators.characters; import static dev.hegel.Generators.compose; import static dev.hegel.Generators.dates; import static dev.hegel.Generators.datetimes; import static dev.hegel.Generators.domains; +import static dev.hegel.Generators.durations; import static dev.hegel.Generators.emails; import static dev.hegel.Generators.floats; +import static dev.hegel.Generators.floats32; import static dev.hegel.Generators.fromRegex; import static dev.hegel.Generators.integers; import static dev.hegel.Generators.ipv4; @@ -31,11 +35,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigInteger; +import java.time.Duration; import java.util.List; import org.junit.jupiter.api.Test; -/** Behaviour/conformance suite exercising every generator against the real engine. */ -class ConformanceTest { +/** Integration tests exercising every generator and combinator against the real engine. */ +class GeneratorBehaviourTest { @Test void booleansAreBooleans() { assertAllExamples(booleans(), b -> b != null); @@ -82,6 +88,73 @@ void binaryRespectsLength() { assertAllExamples(binary(), b -> b != null); } + @Test + void widerNumericsAndDurations() { + assertAllExamples(bytes(), b -> b >= Byte.MIN_VALUE && b <= Byte.MAX_VALUE); + assertAllExamples(bytes((byte) -3, (byte) 3), b -> b >= -3 && b <= 3); + assertAllExamples(Generators.shorts(), s -> s >= Short.MIN_VALUE && s <= Short.MAX_VALUE); + assertAllExamples(Generators.shorts((short) 0, (short) 10), s -> s >= 0 && s <= 10); + assertAllExamples( + bigIntegers(BigInteger.valueOf(-5), BigInteger.valueOf(5)), + v -> v.abs().compareTo(BigInteger.valueOf(5)) <= 0); + // Bounds beyond the long range round-trip through the CBOR bignum encoding (tags 2 and 3). + BigInteger big = BigInteger.TEN.pow(30); + assertAllExamples(bigIntegers(big, big), v -> v.equals(big)); + assertAllExamples(bigIntegers(big.negate(), big.negate()), v -> v.equals(big.negate())); + assertAllExamples(floats32(), f -> true); + assertAllExamples(floats().min(0).max(1).asFloat(), f -> f >= 0f && f <= 1f); + assertAllExamples(durations(), d -> !d.isNegative()); + assertAllExamples( + durations(Duration.ofSeconds(1), Duration.ofSeconds(2)), + d -> d.compareTo(Duration.ofSeconds(1)) >= 0 && d.compareTo(Duration.ofSeconds(2)) <= 0); + } + + @Test + void regexFullmatch() { + assertAllExamples(fromRegex("[0-9]{3}", true), s -> s.matches("[0-9]{3}")); + } + + record Node(int value, Node next) {} + + @Test + @SuppressWarnings("unchecked") + void deferredRecursive() { + Generator[] ref = new Generator[1]; + Generator node = Generators.deferred(() -> ref[0]); + ref[0] = compose(tc -> new Node(tc.draw(integers(0, 9)), tc.draw(optional(node)).orElse(null))); + assertAllExamples(node, n -> n.value() >= 0 && n.value() <= 9); + } + + @Test + void fixedDictBasicAndNonBasic() { + java.util.LinkedHashMap> basic = new java.util.LinkedHashMap<>(); + basic.put("a", integers(0, 9)); + basic.put("b", booleans()); + assertAllExamples( + Generators.fixedDict(basic), + m -> m.keySet().equals(java.util.Set.of("a", "b")) && (Integer) m.get("a") <= 9); + // A non-basic field forces the composite path. + java.util.LinkedHashMap> mixed = new java.util.LinkedHashMap<>(); + mixed.put("x", integers(0, 20).filter(v -> v % 2 == 0)); + mixed.put("y", text().maxSize(2)); + assertAllExamples(Generators.fixedDict(mixed), m -> (Integer) m.get("x") % 2 == 0); + } + + @Test + void arraysFixedLength() { + assertAllExamples( + Generators.arrays(Integer.class, integers(0, 9), 3), + a -> a.length == 3 && java.util.Arrays.stream(a).allMatch(x -> x >= 0 && x <= 9)); + } + + @Test + void nativeTimeTypes() { + assertAllExamples(Generators.localDates(), d -> d != null); + assertAllExamples(Generators.localTimes(), t -> t.getHour() >= 0 && t.getHour() <= 23); + assertAllExamples(Generators.localDateTimes(), dt -> dt.getMonthValue() >= 1); + assertAllExamples(Generators.instants(), i -> i != null); + } + @Test void selectionGenerators() { assertAllExamples(just("k"), v -> v.equals("k")); diff --git a/src/test/java/dev/hegel/GeneratorSmokeTest.java b/src/test/java/dev/hegel/GeneratorSmokeTest.java deleted file mode 100644 index edd86c2..0000000 --- a/src/test/java/dev/hegel/GeneratorSmokeTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package dev.hegel; - -import static dev.hegel.Generators.binary; -import static dev.hegel.Generators.compose; -import static dev.hegel.Generators.dates; -import static dev.hegel.Generators.datetimes; -import static dev.hegel.Generators.domains; -import static dev.hegel.Generators.emails; -import static dev.hegel.Generators.floats; -import static dev.hegel.Generators.fromRegex; -import static dev.hegel.Generators.integers; -import static dev.hegel.Generators.ipv4; -import static dev.hegel.Generators.ipv6; -import static dev.hegel.Generators.maps; -import static dev.hegel.Generators.oneOf; -import static dev.hegel.Generators.optional; -import static dev.hegel.Generators.sampledFrom; -import static dev.hegel.Generators.sets; -import static dev.hegel.Generators.text; -import static dev.hegel.Generators.times; -import static dev.hegel.Generators.tuples; -import static dev.hegel.Generators.urls; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import org.junit.jupiter.api.Test; - -class GeneratorSmokeTest { - @Test - void floatsRespectBounds() { - Hegel.with() - .testCases(50) - .check( - tc -> { - double d = tc.draw(floats().min(0.0).max(1.0)); - assertTrue(d >= 0.0 && d <= 1.0, "out of range: " + d); - }); - } - - @Test - void textRespectsLength() { - Hegel.with() - .testCases(50) - .check( - tc -> { - String s = tc.draw(text().minSize(2).maxSize(5)); - assertTrue(s.codePointCount(0, s.length()) >= 2); - assertTrue(s.codePointCount(0, s.length()) <= 5); - }); - } - - @Test - void binaryRespectsLength() { - Hegel.with() - .testCases(30) - .check( - tc -> { - byte[] b = tc.draw(binary(1, 4)); - assertTrue(b.length >= 1 && b.length <= 4); - }); - } - - @Test - void setsAreUnique() { - Hegel.with() - .testCases(30) - .check( - tc -> { - Set s = tc.draw(sets(integers(0, 5))); - assertTrue(s.size() <= 6); - }); - } - - @Test - void mapsBasicAndNonBasic() { - Hegel.with() - .testCases(30) - .check( - tc -> { - Map m = tc.draw(maps(integers(0, 3), text().maxSize(3))); - assertTrue(m.size() <= 4); - // non-basic key via filter - Map m2 = - tc.draw(maps(integers(0, 10).filter(x -> x % 2 == 0), integers(0, 5))); - m2.keySet().forEach(k -> assertTrue(k % 2 == 0)); - }); - } - - @Test - void tuplesAndOneOfAndOptional() { - Hegel.with() - .testCases(50) - .check( - tc -> { - List t = tc.draw(tuples(integers(0, 9), text().maxSize(2))); - assertTrue((Integer) t.get(0) >= 0); - Object v = tc.draw(oneOf(integers(0, 1), integers(100, 101))); - int iv = (Integer) v; - assertTrue(iv <= 1 || iv >= 100); - Optional o = tc.draw(optional(integers(0, 3))); - o.ifPresent(x -> assertTrue(x >= 0 && x <= 3)); - }); - } - - @Test - void oneOfWithTransformsPath() { - // All basic, with a transform on one alternative -> exercises [index, value] parse. - Hegel.with() - .testCases(50) - .check( - tc -> { - String s = tc.draw(oneOf(integers(0, 5).map(i -> "n" + i), text().maxSize(2))); - assertTrue(s != null); - }); - } - - @Test - void oneOfCompositePath() { - Hegel.with() - .testCases(50) - .check( - tc -> { - int v = tc.draw(oneOf(integers(0, 5).filter(x -> x > 2), integers(10, 12))); - assertTrue(v > 2); - }); - } - - @Test - void sampledFromComposeFlatMap() { - Hegel.with() - .testCases(50) - .check( - tc -> { - String color = tc.draw(sampledFrom("red", "green", "blue")); - assertTrue(List.of("red", "green", "blue").contains(color)); - - List fixed = - tc.draw( - integers(0, 3).flatMap(n -> Generators.lists(Generators.booleans(), n, n))); - assertTrue(fixed.size() <= 3); - - int composed = - tc.draw( - compose( - inner -> { - int a = inner.draw(integers(0, 10)); - int b = inner.draw(integers(0, 10)); - return a + b; - })); - assertTrue(composed >= 0 && composed <= 20); - }); - } - - @Test - void formatGenerators() { - Hegel.with() - .testCases(20) - .check( - tc -> { - assertTrue(tc.draw(emails()).contains("@")); - assertTrue(tc.draw(urls()).length() > 0); - assertTrue(tc.draw(domains()).length() > 0); - assertTrue(tc.draw(ipv4()).contains(".")); - assertTrue(tc.draw(ipv6()).length() > 0); - assertTrue(tc.draw(dates()).length() > 0); - assertTrue(tc.draw(times()).length() > 0); - assertTrue(tc.draw(datetimes()).length() > 0); - assertTrue(tc.draw(fromRegex("[a-z]{3}")).length() >= 0); - }); - } - - @Test - void mapPreservesValues() { - Hegel.with() - .testCases(30) - .check( - tc -> { - int doubled = tc.draw(integers(0, 50).map(x -> x * 2)); - assertTrue(doubled % 2 == 0); - }); - } -} diff --git a/src/test/java/dev/hegel/GeneratorValidationTest.java b/src/test/java/dev/hegel/GeneratorValidationTest.java index 177288a..7568cb0 100644 --- a/src/test/java/dev/hegel/GeneratorValidationTest.java +++ b/src/test/java/dev/hegel/GeneratorValidationTest.java @@ -1,6 +1,8 @@ package dev.hegel; +import static dev.hegel.Generators.bigIntegers; import static dev.hegel.Generators.binary; +import static dev.hegel.Generators.durations; import static dev.hegel.Generators.floats; import static dev.hegel.Generators.integers; import static dev.hegel.Generators.lists; @@ -20,6 +22,12 @@ class GeneratorValidationTest { void numericBounds() { assertThrows(IllegalArgumentException.class, () -> integers(5, 1)); assertThrows(IllegalArgumentException.class, () -> longs(5, 1)); + assertThrows( + IllegalArgumentException.class, + () -> bigIntegers(java.math.BigInteger.ONE, java.math.BigInteger.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> durations(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(1))); } @Test diff --git a/src/test/java/dev/hegel/StatefulTest.java b/src/test/java/dev/hegel/StatefulTest.java new file mode 100644 index 0000000..a6233ef --- /dev/null +++ b/src/test/java/dev/hegel/StatefulTest.java @@ -0,0 +1,93 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import org.junit.jupiter.api.Test; + +class StatefulTest { + + /** A well-behaved counter: multiple rules and an invariant that always holds. */ + static final class Counter implements StateMachine { + private long n = 0; + + @Override + public List rules() { + return List.of( + Rule.of("inc", tc -> n++), + Rule.of("dec", tc -> n--), + Rule.of("add", tc -> n += tc.draw(integers(0, 5)))); + } + + @Override + public List invariants() { + return List.of(Rule.of("finite", tc -> assertTrue(n > Long.MIN_VALUE))); + } + } + + @Test + void counterModelPasses() { + Hegel.with().testCases(30).noDatabase().check(tc -> Stateful.run(tc, new Counter())); + } + + /** A stack whose invariant has a bug: it claims the size never exceeds two. */ + static final class BuggyStack implements StateMachine { + private final Deque stack = new ArrayDeque<>(); + + @Override + public List rules() { + return List.of( + Rule.of("push", tc -> stack.push(tc.draw(integers(0, 100)))), + Rule.of( + "pop", + tc -> { + tc.assume(!stack.isEmpty()); // skip this rule when there is nothing to pop + stack.pop(); + })); + } + + @Override + public List invariants() { + return List.of(Rule.of("smallStack", tc -> assertTrue(stack.size() <= 2))); + } + } + + @Test + void buggyModelFailsAndShrinks() { + assertThrows( + AssertionError.class, + () -> + Hegel.with() + .testCases(300) + .noDatabase() + .check(tc -> Stateful.run(tc, new BuggyStack()))); + } + + @Test + void singleRuleNoInvariantsSucceeds() { + // Single rule (no index draw), no invariants (empty post-step loop). + Hegel.with() + .testCases(20) + .noDatabase() + .check(tc -> Stateful.run(tc, () -> List.of(Rule.of("noop", t -> {})))); + } + + @Test + void rulesThatAlwaysRejectTerminate() { + // The rule always rejects; the attempt budget must end the run rather than loop forever. + Hegel.with() + .testCases(20) + .noDatabase() + .check(tc -> Stateful.run(tc, () -> List.of(Rule.of("nope", t -> t.assume(false))))); + } + + @Test + void emptyRulesRejected() { + TestCase tc = new TestCase((DataSource) null, false, System.err); + assertThrows(IllegalArgumentException.class, () -> Stateful.run(tc, List::of)); + } +}