diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 47e45f8..f4783ed 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,36 +1,78 @@ # hegel-java — developer notes -Property-based testing for Java, binding the native Hegel engine (`libhegel`) in-process over the -Java Foreign Function & Memory API (FFM, `java.lang.foreign`). No JNI, no cgo-equivalent. +Property-based testing for Java, binding the native Hegel engine (`libhegel`) in-process. Two +published frontend artifacts share one API and one source tree: `dev.hegel:hegel` binds over the +Java Foreign Function & Memory API (FFM, `java.lang.foreign`, Java 22+) and `dev.hegel:hegel-jna` +binds over JNA (Java 17+). Users depend on exactly one. Both depend on a third, deliberately +minimal artifact, `dev.hegel:hegel-lowlevel` (Java 17+): the binding contract (`Libhegel`, `Abi`, +`LibhegelBackend`, `LibraryLoader`) for people writing their own binding or a from-scratch frontend. +It carries no generators, runner, or printing. (`hegel-core` was an unrelated, deprecated +Hypothesis wrapper.) This implementation was generated by Claude following the `hegel-implementation-guide` skills. The authoritative references are hegel-rust (the engine and canonical client) and its C ABI header `hegel-c/include/hegel.h`. +## Layout + +Multi-module Maven build (`dev.hegel:hegel-parent`, not itself useful to consumers): + +- `hegel-lowlevel/` — a normal Maven module, package `dev.hegel.lowlevel`: the public `Libhegel` + interface (one method per `hegel_*` function; raw `long` handles and return codes), `Abi` + constants, `LibhegelException`, the `LibhegelBackend` SPI, `LibraryLoader`, and the + `BuildInfo` template (`src/main/java-templates`, filtered from ``). Tests are + fake-only (`LibraryLoaderTest`, `LibhegelLoadTest` with a `StubBackend` service provider on the + test classpath); surefire sets `HEGEL_LIBHEGEL_PATH` to a dummy file so `Libhegel.load()` + resolves without a bundled native. Bundles no natives. +- `shared/` — **not a Maven module**: the backend-neutral frontend source tree (`src/main/java`, + `src/test/java`) compiled into *both* frontend jars via build-helper-maven-plugin + `add-source`/`add-test-source`. Everything except the concrete binding lives here, including + `FakeLibhegel` and the whole behaviour suite, which therefore runs against both backends. +- `hegel/` — the FFM artifact: `RealLibhegel` + `FfmBackend` (main), `RealLibhegelTest` + + `FfmCoverageTest` (test), and the JPMS `module-consumer` invoker IT under `src/it`. +- `hegel-jna/` — the JNA artifact: `JnaLibhegel` + `JnaBackend` (main), `JnaLibhegelTest`. + +Each frontend jar registers its backend in `META-INF/services/dev.hegel.lowlevel.LibhegelBackend`; +the shared `Engine` calls `Libhegel.load(path)`, which finds it via `ServiceLoader` (automatic +modules may use any service, so this also works on the module path — the IT proves it). The +bindings throw `HegelException`, which extends `dev.hegel.lowlevel.LibhegelException`; the runner +aborts on the parent type so third-party bindings behave the same. IDE caveat: IntelliJ attaches a +shared source root to only one module at a time (duplicate content roots are unsupported); the +Maven CLI is authoritative. + ## Build & test - `just coverage` / `mvn verify` — runs all tests and enforces **100% instruction + branch - coverage** (JaCoCo). This is a hard gate. -- `just test` / `mvn test` — full suite, no coverage gate. -- `mvn test -Dtest=CborTest` / `-Dtest=CborTest#decodesTag91` — one class / one method. - `-Dtest='*Conformance*,*Behaviour*'` is what `just conformance` runs. + coverage** (JaCoCo, per module). This is a hard gate. +- `just test` / `mvn test` — full suite, no coverage gate. Building the full reactor needs JDK + 22+ (the FFM module); `just test-jna` / `just coverage-jna` build only parent + `hegel-jna` + and work on JDK 17+ (CI runs them on 17 and 21). +- `mvn test -Dtest=RunnerTest` / `-Dtest=RunnerTest#happyPathMarksValidAndFreesEverything` — one + class / one method. `-Dtest='*Conformance*,*Behaviour*'` is what `just conformance` runs. - `just conformance` — the behaviour suite against the real engine. - `just build-libhegel` — build `libhegel` from a sibling `../hegel-rust` checkout. - `just format` / `just lint` — palantir-java-format via spotless-maven-plugin. - `just check` — full CI gate: lint + coverage + docs. -Java 22 (`maven.compiler.release`) for the FFM API; tests run with -`--enable-native-access=ALL-UNNAMED` (the `hegel.argLine` property). The pinned engine version -(`` in `pom.xml`) is filtered into `BuildInfo.ENGINE_VERSION` from -`src/main/java-templates/dev/hegel/BuildInfo.java` (maven templating-plugin → generated-sources); +`maven.compiler.release` is 22 for the `hegel` module (FFM) and 17 for `hegel-lowlevel`, +`hegel-jna`, and the shared tree; keep shared and lowlevel code Java-17-clean. Both modules' tests run with +`--enable-native-access=ALL-UNNAMED` (the `hegel.argLine` property — FFM needs it on 22+, JNA on +24+ under JEP 472; the flag is accepted on every supported JDK). The +pinned engine version (`` in the parent `pom.xml`) is filtered into +`BuildInfo.ENGINE_VERSION` from `hegel-lowlevel/src/main/java-templates/dev/hegel/lowlevel/BuildInfo.java` +(maven-resources-plugin filtering → generated-sources, added to the compile path by build-helper); bump that property to ship a new engine. A user-supplied `$HEGEL_LIBHEGEL_PATH` of a different version triggers a warning against `BuildInfo.ENGINE_VERSION`. `libhegel` resolves from `$HEGEL_LIBHEGEL_PATH` (explicit override), else the OS's standard -shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS), else the -native bundled in the jar for the host OS/arch (unpacked to a per-user cache). The bundled libraries are fetched at build time by +shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS, `PATH` on +Windows), else the +native bundled in the jar for the host OS/arch (unpacked to a per-user cache; the cache is +best-effort — if it cannot be read or written, e.g. under a sandbox that denies writes to the user +cache dir, the native is extracted to a fresh directory under the system temp dir instead). The bundled libraries are fetched at build time by `scripts/fetch_natives.py` (wired into Maven's `generate-resources` phase), which discovers whatever -shared objects the pinned `` release publishes — no runtime download. The fetch is **strict** — +shared objects the pinned `` release (tagged `libhegel-v` in hegel-rust; +the plain `v` tags belong to the `hegeltest` crate) publishes — no runtime download. The fetch is **strict** — because the bundled native is the only way end users get the engine (no runtime-download fallback), it fails the build rather than silently producing a jar without natives. For local engine work, build a `libhegel` and point `$HEGEL_LIBHEGEL_PATH` at it (`just build-libhegel` builds @@ -39,48 +81,161 @@ fetch (required when offline, since the fetch is otherwise a hard error). ## Architecture -Data crosses the FFI boundary as **CBOR**: generator *schemas* in, generated *values* out. Users -never see CBOR or schemas. +Data crosses the FFI boundary through libhegel's **typed draw functions** +(`hegel_generate_integer`, `hegel_generate_float`, `hegel_generate_string` through opaque +`hegel_string_generator_t` handles, structured date/time/datetime/uuid/ip draws, …). Every fallible +call takes a per-thread `hegel_context_t*` first argument and returns a `hegel_result_t`; results +come back through out-parameters, and every handle is caller-owned and explicitly freed. Layers live in `dev.hegel`; the concrete generator implementations are in the `dev.hegel.generators` subpackage (one class per generator: `IntegerGenerator`, `TextGenerator`, `ListGenerator`, `RegexGenerator`, `EmailGenerator`, `DateTimeGenerator`, `Derive`/`RecordGenerator`, etc.). The -public `Generator`/`TestCase`/`Generators`/`Hegel` surface stays in `dev.hegel`. - -- **FFI binding** — `Libhegel` (a fakeable interface) and `RealLibhegel` (FFM). `LibraryLoader` - resolves the library (override / OS library path / jar-bundled native unpacked to a cache); `Engine` is - the process-wide lazy holder of the loaded shared object (with a test hook) — it caches only the - immutable, thread-safe library, never per-test state. - `Cbor` encodes schemas and decodes values (Tag 91 / WTF-8, BigInteger ints, float widths). `Abi` - holds the C constants. -- **Per-case primitives** — `DataSource` is the abstraction generators draw against; `LiveDataSource` - wraps the engine, translating return codes (`StopTest` → OVERRUN, `AssumeRejected` → INVALID, - other negatives → `HegelException`) and short-circuiting once a case is aborted. -- **Run loop** — `Runner` builds the settings handle, drives `hegel_run_start` → - `hegel_next_test_case` → `hegel_mark_complete`, and turns the result into a pass or an - `AssertionError` carrying the minimal counterexample. `Settings` is the immutable config; its - closed-state setting types live alongside it — `Mode` (`TEST_RUN` / `SINGLE_TEST_CASE`), - `Database` (unset / disabled / path), `OptBoolean` (default / true / false, for annotation - attributes whose underlying default is environment-dependent, e.g. `derandomize`). -- **Generators** — `Generator` (public) with `map`/`filter`/`flatMap`. The basic/composite dual - path is driven by `Generator.asBasic()`: a schema-describable generator returns a `BasicGenerator` - (CBOR schema + a client-side `parse` function) and gets the default single-call `doDraw`; - everything else returns `null` and overrides `doDraw`. `map` on a basic generator composes the - parse over the same schema (`mapBasic`, one engine call, shrinks as well as the original); - otherwise (`MappedGenerator`/`FilteredGenerator`/`FlatMappedGenerator`/`CompositeGenerator`) it - brackets the draw in a span. `Generators` is the factory facade. Collection/`oneOf`/tuple - generators return a basic schema from `asBasic()` only when their elements are basic, and fall - back to the engine collection API otherwise. -- **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). +public `Generator`/`TestCase`/`Generators`/`Hegel`/`Stateful` surface stays in `dev.hegel`. + +- **FFI binding** — `dev.hegel.lowlevel.Libhegel` (a public, fakeable, backend-neutral + interface: opaque handles cross as raw `long` addresses, 0 = NULL, so it never mentions any FFI + library's types) with one implementation per frontend artifact: `RealLibhegel` (FFM, in + `hegel/`) and `JnaLibhegel` (JNA, in `hegel-jna/`), each registered as a `LibhegelBackend` + service provider. `LibraryLoader` (lowlevel) resolves the library (override / OS library path / + jar-bundled native unpacked to a cache — the natives live in the frontend jar, found through the + classpath); `Engine` (shared) is the process-wide lazy holder of the loaded shared object (with a + test hook) — it caches only the immutable, thread-safe library, never per-test state. Both bindings keep one libhegel error + context per thread (read back by `lastErrorMessage()`), pass date/time/datetime structs by + value, copy engine-allocated string/bytes buffers out and free them, and bridge the per-run + output callback (`hegel_output_callback_t`) to a `Consumer` — an FFM upcall stub whose + arena lives until `runFree`, or a JNA `Callback` strongly referenced until `runFree`. In the + JNA binding, C `bool` crosses as `byte` (JNA's default boolean mapping is a 32-bit int) and + `size_t` as `long` (the bundled natives are all 64-bit). `Abi` (lowlevel) holds the C constants; `Label` (shared) holds the frontend's span labels — + opaque `u64`s derived from `dev.hegel.` names with the same FNV-1a hash as the engine's + `hegel_label_from_name` (`Label.of`) and `hegel_label_combine` (`Label.combine`); the engine has + no predefined label enum since 0.39. +- **Per-case primitives** — `DataSource` is the abstraction generators draw against; + `LiveDataSource` wraps the engine, translating return codes (`StopTest` → OVERRUN, + `AssumeRejected` → INVALID, `INVALID_ARG` → `IllegalArgumentException` with the engine's + diagnostic, other negatives → `HegelException`) and short-circuiting once a case is aborted. +- **Run loop** — `Runner` builds the settings handle and drives `hegel_run_start` → + `hegel_next_test_case` → `hegel_mark_complete`. The engine owns the *whole* run — generation, + shrinking, the replays that confirm a failure, and (since 0.44) the final replay of every failure + it is about to report — so the runner never replays anything itself. It reads + `hegel_test_case_should_capture` once per case: a *stamped* case's `TestCase` records its + top-level draws and notes (`isFinal()` exposes the stamp), and every interesting case's exception + is kept per origin in a capture map (a stamped capture outranks an unstamped one, newest wins at + equal rank — the unstamped discovery of a failure that never fails again is all there is for an + *unconfirmed* nondeterministic failure). After the loop drains, `hegel_run_result` yields PASSED, + FAILED, or ERROR. On FAILED each distinct failure is built from its origin's capture: the recorded + draws/notes are replayed to the reporter (`caseStarted(true)` … `caseFinished(true)`), and the + `Failure` carries the body's own exception, the reproduce blob (`hegel_failure_reproduction_blob`, + null for an unconfirmed failure and for a blob replay) and the **caveat** + (`hegel_failure_caveat`, non-null for a nondeterministic failure — `Failure.nondeterministic()`; + the printing reporter prints it as `note: …`). A failure whose origin has no capture is an + internal error. `Settings.reproduceFailure` drives the same loop over `hegel_run_start_blob` + (the engine replays until a replay fails, under its budget): PASSED → `Runner.STALE_BLOB` + `HegelException`, ERROR → "blob is not valid" `HegelException`. There is no Java-side flaky + detection any more: the engine handles nondeterminism per `nondeterminismStrictness` + (`NondeterminismStrictness.QUIET`/`WARN` confirm-by-replay and report with a caveat; `ERROR` + aborts the run, surfacing as the engine's own `Flaky test detected` message). The runner returns a `RunReport` (status, client-side `RunStatistics` counted + per `mark_complete` — the C ABI has no counter accessor — engine error, failures) and never + prints: everything goes through the run's `Reporter` (`Reporter.printing(System.err)` is the + default and reproduces the classic output; `Reporter.silent()` for library use). `Hegel.run` + returns the report; `Hegel.test` = `run` + `RunReport.throwIfFailed()`, which rethrows a single + failure as-is (checked exceptions included), aggregates several into an `AssertionError`, and + maps ERROR to `HealthCheckFailure`/`HegelException`. `HegelException` (binding/engine errors) is + always thrown, never reported. `Settings` is the immutable config; its closed-state setting types live + alongside it — `Backend` (auto = leave it to the engine's profile, which picks urandom inside Antithesis / default / urandom), `Database`, `OptBoolean`, `NondeterminismStrictness` (`DEFAULT` = leave it to the engine) — + plus `printBlob` (print a copy-pasteable reproducer per failure) and `reproduceFailure` (replay + a stored blob instead of running the property). `testCases`, `printBlob`, `derandomize`, `seed`, + `nondeterminismStrictness` and `database` are nullable ("unset"): `Runner.applySettings` sends only what the user set, so + the engine's resolution — its profile (`hegel.toml`, the shipped `development`/`ci`/`workload` + profiles) with the `HEGEL_TEST_CASES`/`HEGEL_DATABASE`/`HEGEL_SEED`/`HEGEL_DERANDOMIZE`/ + `HEGEL_PRINT_BLOB`/`HEGEL_NONDETERMINISM_STRICTNESS` variables applied over it in `hegel_settings_new` — stands for the rest, and + explicit Java settings win over both. There is no Java-side CI detection (the engine's `ci` + profile covers it). `Libhegel.settingsNew` returns a raw result code because a malformed + variable or `hegel.toml` fails it with `E_INVALID_ARG` (→ `IllegalArgumentException`); after + applying, the runner reads `settingsGetTestCases`/`settingsGetPrintBlob`/`settingsGetNondeterminismStrictness` back and hands the + *effective* `Settings` to the reporter and the run. Verbosity and `reportMultipleFailures` keep + Java-side defaults and are always sent. `EnvironmentTest` covers the variables and `hegel.toml` + through a child JVM (`EnvironmentFixture`), since the engine reads its own process environment. + There is no single-test-case mode (the engine + dropped it in 0.35); `testCases(1)` is the one-case configuration. +- **Generators** — `Generator` (public) with `map`/`filter`/`flatMap`. Leaf generators call the + typed draw bridges on `TestCase` (`generateInteger`, `generateFloat`, `generateString`, …); + composite generators (collections via the engine's `new_collection`/`collection_more` protocol, + `oneOf`, tuples, `map`/`filter`/`flatMap`) compose other generators' `doDraw` inside labeled + spans, mirroring hegel-rust. String-shaped generators (`text`/`characters`/regex/email/url/ + domain) build a validated `hegel_string_generator_t` handle once per configuration and cache it + (`HandleCache`, rebuilt if a test swaps the `Engine` binding); `StringGeneratorHandle` frees the + engine allocation via a `Cleaner` when unreachable. `Generators` is the factory facade. +- **Public API** — `Hegel.test`/`Hegel.run`, `TestCase` (`draw`/`assume`/`note`/`target`/`isFinal`/ + `span`), `Label` (span labels; `Label.of(name)` mints FNV-1a labels), `Reporter`, `RunReport`, + `Settings.infrastructurePackages` (extra class-name prefixes skipped by `Runner.originOf`), the + `@HegelTest` + annotation + `HegelTestExtension` (a JUnit 5 `TestTemplateInvocationContextProvider` that drives + the engine loop and invokes the user method per case). +- **Stateful testing** — `Stateful.run(machine, tc[, options])` reflects `@Rule`/`@Invariant` + methods (sorted by name for determinism) and registers them via `hegel_new_state_machine`: + `@Rule(group = "g")` names become `rule_groups` ids (0.. in first-appearance order; rules that + name none share `Stateful.ANONYMOUS_GROUP`, so a plain machine is all-zeros), `@Rule(weight = w)` + becomes the `rule_weights` array (validated finite and positive in Java; `null` — the engine's + all-equal default — when every rule is at 1; the engine samples proportionally among the + *enabled* rules, so weights are hints, and tests check them per case, not in aggregate), and + `Stateful.Options` carries the per-machine `step_count` (`Stateful.DEFAULT_STEP_COUNT` = 50; the + engine has no default) and the concurrency bounds (default `1, 1`). The engine draws the + concurrency level and the driver runs exactly that many workers. Two drivers share the + registration and the round protocol — `hegel_state_machine_next_group` opens each round on the + root handle (or reports `HEGEL_STATE_MACHINE_DONE`, which is `INT64_MIN`), + `hegel_state_machine_next_rule(worker)` hands out the round's rules until the worker's join point, + a rule that fails its own assumption is reported with `hegel_state_machine_rule_rejected(worker)`, + and at each join point `hegel_state_machine_should_check_invariant` (root handle) decides which + invariants run — always for `@Invariant(alwaysRun = true)`, sampled otherwise; the initial and + final checks run every invariant unconditionally: + - `maxConcurrency == 1` → the **sequential** driver, unchanged from before concurrency existed: + everything on the calling thread and the root handle as worker 0, one `STATEFUL_RULE` span + per round (discarded on rejection), `Step N: rule` notes. + - `maxConcurrency > 1` → the **concurrent** driver, modelled on hegel-rust's `run_concurrent_machine` + (even when the drawn level is 1, so a run's output format is uniform). Persistent daemon + worker threads (`hegel-worker-N`) live for the test case; per round the root thread notes the + header (`---------------- Round k: group "name" ----------------`), makes a **fresh clone per + worker** (`hegel_test_case_clone` + `hegel_test_case_set_worker`; cloning is a draw and can + return `E_STOP_TEST` on a replay, hence the raw-rc convention for `Libhegel.testCaseClone`), + hands each worker its clone through a queue, waits for one event per worker (the join point), + absorbs the workers' buffered draws/notes into the root in worker order (each line stamped + `[worker N +X.XXXms]` client-side — Java notes never go through `hegel_note`, so the engine's + own attribution is invisible here; draw names stay unique family-wide through the root's + synchronized name counter and are recorded plain), frees the clones, and resolves the round in + Rust's precedence: a control error (`LibhegelException`/`IllegalArgumentException`, or a worker + that exited without reporting) is rethrown first; then an overrun or an engine-level `E_ASSUME` + concludes the case, dropping any failures found alongside with a `Dropped concurrent failure + from worker N` note; otherwise the lowest worker's failure is rethrown as-is (its stack trace + is the worker's, so `originOf` still works) and the rest are noted as dropped. There is no + cancellation: a failing worker ends its own round and the others finish theirs (an overrun + aborts the family engine-side anyway). No spans are opened in the concurrent path (none of the + bindings do; the engine owns rule structure). The `Reporter` is only ever called from the + driving thread: verbose output of worker lines is delivered at the join. Workers are stopped + and joined in a `finally` before any leftover clone is freed. + The machine handle is freed in a `finally`. `Pool` (sequential; bound to its creating handle, + unsynchronised) and `ConcurrentPool` (one monitor across the engine call and the map update; + `add(tc, value)` draws through the calling rule's handle) track previously generated values over + the engine's pool primitives so rules can reuse or consume them. `LiveDataSource` maps + `E_CONCURRENT_USE` to a `HegelException` naming the fix (draw through the rule's own `TestCase`, + use `ConcurrentPool`). `FakeLibhegel.concurrentRounds` scripts per-worker rule queues so the + driver's join-point layout and every resolution path are tested deterministically + (`ConcurrentStatefulDriverTest`); `ConcurrentStatefulTest` covers the real engine (parallelism, + group exclusion, a found lost-update race, pools, blobs). - **Derivation** — `dev.hegel.generators.Derive` + `RecordGenerator` build generators from records, enums, scalars, and generic `List`/`Set`/`Optional`/`Map` by reflection. ## Coverage notes -Two genuinely-unreachable defensive catch blocks are excluded via `@Generated` (JaCoCo ignores -`*Generated*`-named annotations): the `NoSuchAlgorithmException` for SHA-256 and the reflective -dispatch in `HegelTestExtension`. Everything else is covered by real-engine integration tests plus -`FakeLibhegel`-driven error-path tests. The engine's `collection_more`/`new_collection` out-params -are read unconditionally (the engine signals exhaustion on the following draw, not at those calls). +The 100% gate applies per module, so each backend jar carries its own binding-edge tests +(`RealLibhegelTest` + `FfmCoverageTest` for FFM, `JnaLibhegelTest` for JNA) on top of the shared +suites. A few genuinely-unreachable defensive blocks are excluded via `@Generated` (JaCoCo ignores +`*Generated*`-named annotations): the `NoSuchAlgorithmException` for SHA-256, the lookup of the +output-callback bridge method, and the `IllegalAccessException` after `setAccessible(true)` +succeeded in `Stateful`. Everything else is covered by real-engine integration tests plus +`FakeLibhegel`-driven error-path tests. + +JaCoCo gotcha: a call to a helper that *always* throws leaves the call site's own instructions +uncovered (they are attributed to the next probe, which never runs), and `throw helper(x)` leaves +a dead `athrow`. Make such helpers return on some tested path (see `RunReport.unchecked`). Also +note the test suite lives in `dev.hegel`, so `Runner.originOf` treats test frames as +infrastructure — origin assertions cannot name a test file. diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md new file mode 100644 index 0000000..b899f62 --- /dev/null +++ b/.claude/skills/changelog/SKILL.md @@ -0,0 +1,138 @@ +--- +name: changelog +description: "Changelog style guide for writing RELEASE.md files. Use when creating or reviewing RELEASE.md, writing changelog entries, or preparing a PR that needs release notes." +--- + +# Changelog Style Guide + +This guide describes the style for writing `RELEASE.md` files for hegel-java. The style is modeled on the [Hypothesis changelog](https://hypothesis.readthedocs.io/en/latest/changes.html). + +## Choosing `RELEASE_TYPE` + +hegel-java is currently zerover (`0.x.y`), so the usual semver mapping does **not** apply. While we are pre-1.0: + +- **`patch`** — Bug fixes, internal changes, **and new features / non-breaking API additions**. The default choice. +- **`minor`** — **Breaking changes only.** Any change that requires users to update their code (renamed/removed APIs, changed signatures, behavior changes that could break downstream tests) is a minor bump. +- **`major`** — Not used while we are zerover. Reserve for the eventual 1.0 and beyond. + +If you find yourself reaching for `minor` because the change feels "big," check whether it actually breaks any caller. A large new feature that adds API surface without removing or changing existing behavior is still a `patch`. + +## Opening sentence pattern + +Every entry should open with a sentence that signals the scope and nature of the change: + +- **Patch (fixes, improvements, new features):** Start with `"This patch ..."` +- **Minor (breaking changes):** Start with `"This release ..."` and explain migration +- **Tiny internal-only changes:** A bare sentence is fine — `"Internal refactoring."` or `"Clean up some internal code."` + +The opening verb should tell the reader what *kind* of change this is: + +| Change type | RELEASE_TYPE | Opening pattern | +|---|---|---| +| Bug fix | `patch` | `"This patch fixes ..."` or `"Fix ..."` | +| New feature | `patch` | `"This patch adds ..."` | +| Improvement | `patch` | `"This patch improves ..."` | +| Performance | `patch` | `"This patch improves the performance of ..."` or `"Optimize ..."` | +| Deprecation | `minor` | `"This release deprecates ..."` | +| Breaking change | `minor` | `"This release changes ..."` (then explain migration) | +| Internal-only | `patch` | `"Internal refactoring."` / `"Refactor some internals."` / `"Clean up some internal code."` | + +## Describe the user impact, not the implementation + +Bad: "Cache the encoded CBOR schema bytes in `BasicGenerator` instead of re-encoding them on every draw." + +Good: "This patch improves the performance of value generation, particularly for tests that draw many values from the same generator. Generator schemas are now encoded once per generator rather than once per draw." + +Bad: "Fixed a bug in `TextGenerator`." + +Good: "This patch fixes `Generators.text().minSize(n)` occasionally producing strings shorter than `n` characters when the generated text contained codepoints outside the Basic Multilingual Plane." + +## Length calibration + +- **Internal-only changes:** 1 sentence. (`"Refactor some internals."`) +- **Simple bug fixes:** 1-3 sentences. Describe the bug and what changed. +- **New features:** 1-2 short paragraphs. Describe what it does and why it's useful. +- **Breaking changes / API changes:** Multiple paragraphs. Include before/after code examples and migration guidance. + +Don't pad entries. If a change can be described in one sentence, use one sentence. + +## Code examples + +Include fenced code blocks for: +- New API features (show usage) +- Breaking changes (show before/after) +- Anything where seeing the code is clearer than describing it + +Don't include code blocks for bug fixes or internal changes. + +## References + +- Reference GitHub issues when relevant: `([#123](https://github.com/hegeldev/hegel-java/issues/123))` +- Reference previous versions when building on prior work +- Reference related libraries/specs when relevant + +## Tone + +- Third person, present tense for describing behavior +- Professional but conversational — be direct, not formal +- Honest about uncertainty: `"This should improve performance"`, `"We expect this to..."`, `"In some cases this may..."` +- It's okay to briefly explain *why* a change was made if the motivation isn't obvious + +## Things to avoid + +- No emojis +- No bullet lists for single-topic entries (use them for multi-topic entries like API cleanups) +- No commit hashes or PR numbers in the text (issue numbers are fine) +- Don't describe the implementation when you can describe the effect +- Don't use vague language like `"various improvements"` — be specific about what changed +- Don't add marketing language or hype + +## Examples + +**Good patch (bug fix):** + +``` +RELEASE_TYPE: patch + +This patch fixes `Generators.fromRegex` failing to generate strings for patterns containing nested character-class negations. +``` + +**Good patch (internal):** + +``` +RELEASE_TYPE: patch + +Internal refactoring of the CBOR encoding and decoding code. +``` + +**Good patch (new feature):** + +``` +RELEASE_TYPE: patch + +This patch adds `Settings.suppressHealthCheck`, which disables individual health checks for a single test run. + +This is useful when a health check is a false positive for your test — for example, a `filter` that is legitimately expensive but still finds enough valid inputs. +``` + +**Good minor (breaking change):** + +```` +RELEASE_TYPE: minor + +This release changes `Generators.maps` to take an explicit key generator instead of always generating string keys. + +Before: + +```java +Generator> gen = Generators.maps(Generators.integers()); +``` + +After: + +```java +Generator> gen = Generators.maps(Generators.text(), Generators.integers()); +``` + +To keep the previous behavior, pass `Generators.text()` as the key generator. +```` diff --git a/.github/scripts/release.py b/.github/scripts/release.py index 54ba28b..4594a9e 100644 --- a/.github/scripts/release.py +++ b/.github/scripts/release.py @@ -5,20 +5,43 @@ Maven Central via the `release` profile, and then records the release in git (commit + tag + GitHub release). -The publish happens *before* the tag is pushed: if `mvn deploy` fails, nothing is committed or -tagged, so a retry starts clean. `push-or-pr` pushes the release commit to main afterwards, -falling back to a PR if main has diverged. +The publish happens *before* the tag is pushed: if `mvn deploy` fails before the bundle is +uploaded, nothing is committed or tagged, so a retry starts clean. If it fails *after* the upload +(the deployment publishes server-side from that point on), the version is checked against the +Central API and the release continues if it is live — see `deploy_and_verify`. `push-or-pr` +pushes the release commit to main afterwards, falling back to a PR if main has diverged. """ import argparse +import base64 +import json import os import re import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent POM = ROOT / "pom.xml" +# Every module pom carries a that must move with the release; a module left +# behind resolves the previous parent from the repository instead of the reactor. +MODULE_POMS = [ROOT / "hegel-lowlevel" / "pom.xml", ROOT / "hegel" / "pom.xml", ROOT / "hegel-jna" / "pom.xml"] +PUBLISHED_ARTIFACTS = ["hegel-lowlevel", "hegel", "hegel-jna"] + +# Printed by central-publishing-maven-plugin once the bundle is on the portal. From that point +# the deployment validates and publishes server-side (autoPublish) no matter how the mvn +# process exits. +UPLOAD_MARKER = "Uploaded bundle successfully" + +CENTRAL_PUBLISHED_URL = "https://central.sonatype.com/api/v1/publisher/published" +# How long to keep polling Central for an uploaded deployment to publish, and the poll interval. +# Publishing normally completes within a few minutes of upload. +PUBLISH_WAIT_SECONDS = 10 * 60 +PUBLISH_POLL_SECONDS = 30 def git(*args: str) -> None: @@ -26,10 +49,13 @@ def git(*args: str) -> None: def is_source_file(path: str) -> bool: - # A PR that changes published source (src/main/**) or the pom must carry a RELEASE.md; - # test-only and tooling changes don't. Mirrors the per-library source definition the other - # Hegel libraries use (which likewise exclude their test trees). - return path.startswith("src/main/") or path == "pom.xml" + # A PR that changes published source (any module's src/main/**) or a build pom must carry a + # RELEASE.md; test-only and tooling changes don't. Mirrors the per-library source definition + # the other Hegel libraries use (which likewise exclude their test trees). + return ( + path.startswith(("shared/src/main/", "hegel-lowlevel/src/main/", "hegel/src/main/", "hegel-jna/src/main/")) + or path in ("pom.xml", "hegel-lowlevel/pom.xml", "hegel/pom.xml", "hegel-jna/pom.xml") + ) def parse_release_file(path: Path) -> tuple[str, str]: @@ -62,9 +88,9 @@ def bump_version(current: str, release_type: str) -> str: def pom_version() -> str: - """The last released version, read from the pom's project (the first - element; modelVersion uses a different tag). Seeded at ``0.0.0`` before the first release, so - the bootstrap ``RELEASE_TYPE: minor`` lands at ``0.1.0``.""" + """The last released version, read from the parent pom's project (the first + element; modelVersion uses a different tag). Seeded at ``0.0.0`` before the first + release, so the bootstrap ``RELEASE_TYPE: minor`` lands at ``0.1.0``.""" match = re.search(r"([^<]+)", POM.read_text()) if match is None: raise ValueError("could not find in pom.xml") @@ -72,8 +98,12 @@ def pom_version() -> str: def set_pom_version(new_version: str) -> None: - text = POM.read_text() - POM.write_text(re.sub(r"[^<]+", f"{new_version}", text, count=1)) + # The first is the project version in the parent pom and the reference in + # each module pom (modelVersion uses a different tag, and modules declare no version of + # their own). + for pom in [POM, *MODULE_POMS]: + text = pom.read_text() + pom.write_text(re.sub(r"[^<]+", f"{new_version}", text, count=1)) def add_changelog(path: Path, *, version: str, content: str) -> None: @@ -90,6 +120,68 @@ def add_changelog(path: Path, *, version: str, content: str) -> None: path.write_text(f"{existing[: idx + 1]}\n{entry}{existing[idx + 1 :]}") +def run_deploy(mvn_args: list[str]) -> tuple[int, bool]: + """Run `mvn deploy`, echoing its output, and return (exit code, whether the bundle upload + succeeded).""" + process = subprocess.Popen(mvn_args, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + uploaded = False + assert process.stdout is not None + for line in process.stdout: + print(line, end="", flush=True) + if UPLOAD_MARKER in line: + uploaded = True + return process.wait(), uploaded + + +def is_published(version: str) -> bool: + """Ask the Central Publisher API whether every published artifact (dev.hegel:hegel-lowlevel, + dev.hegel:hegel, and dev.hegel:hegel-jna) at {version} is live on Maven Central. Network and server errors count + as "not (yet) published", so a transiently failing status endpoint — the very thing being + recovered from — just means polling again.""" + credentials = f"{os.environ['CENTRAL_TOKEN_USER']}:{os.environ['CENTRAL_TOKEN_PASS']}" + token = base64.b64encode(credentials.encode()).decode() + for name in PUBLISHED_ARTIFACTS: + query = urllib.parse.urlencode({"namespace": "dev.hegel", "name": name, "version": version}) + request = urllib.request.Request( + f"{CENTRAL_PUBLISHED_URL}?{query}", headers={"Authorization": f"Bearer {token}"} + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + if not json.load(response).get("published"): + return False + except (urllib.error.URLError, TimeoutError, ValueError): + return False + return True + + +def deploy_and_verify(mvn_args: list[str], version: str) -> None: + """Run `mvn deploy`, tolerating failures that happen after the version is safe on Central. + + The deploy can exit nonzero even though the release went (or is going) out: the plugin's + wait-until-published poll can hit a transient error (e.g. a 502) after the bundle uploaded, + and on a re-run of a failed release job the upload is rejected because the version already + exists. Aborting in those cases strands a published version with no tag, changelog, or + GitHub release, and the next release attempt then collides with it. So on failure, this + checks whether the version is live on Central — polling for a while if the upload succeeded, + a single check otherwise — and returns normally if it is, letting the git bookkeeping + proceed.""" + returncode, uploaded = run_deploy(mvn_args) + if returncode == 0: + return + error = subprocess.CalledProcessError(returncode, mvn_args) + if "CENTRAL_TOKEN_USER" not in os.environ or "CENTRAL_TOKEN_PASS" not in os.environ: + raise error + deadline = time.monotonic() + (PUBLISH_WAIT_SECONDS if uploaded else 0) + while True: + if is_published(version): + print(f"mvn deploy failed (exit {returncode}) but {version} is published on Central; continuing.") + return + if time.monotonic() >= deadline: + raise error + print(f"mvn deploy failed (exit {returncode}) after upload; waiting for {version} to publish...") + time.sleep(PUBLISH_POLL_SECONDS) + + def check(base_ref: str) -> None: """PR gate (run by check-release.yml): if the PR changes source, require a well-formed RELEASE.md. A no-op for PRs that touch no source.""" @@ -151,7 +243,7 @@ def release() -> None: # Publish to Maven Central. The release profile builds + signs the jar/sources/javadoc, # fails loudly if the bundled natives are missing, and uploads + publishes the deployment # (autoPublish + waitUntil=published are configured on the plugin). Done before any git tag - # so a failure leaves nothing to unwind. + # so a pre-upload failure leaves nothing to unwind. mvn_args = ["mvn", "-B", "-P", "release", "deploy"] # Pin the signing key by fingerprint (GPG_KEYNAME) so the build signs with the Hegel release # key specifically and fails if that key isn't the one in the keyring, rather than silently @@ -159,7 +251,7 @@ def release() -> None: keyname = os.environ.get("GPG_KEYNAME") if keyname: mvn_args.append(f"-Dgpg.keyname={keyname}") - subprocess.run(mvn_args, check=True, cwd=ROOT) + deploy_and_verify(mvn_args, new_version) app_slug = os.environ["HEGEL_RELEASE_APP_SLUG"] bot_user_id = subprocess.run( @@ -171,7 +263,7 @@ def release() -> None: git("config", "user.name", f"{app_slug}[bot]") git("config", "user.email", f"{bot_user_id}+{app_slug}[bot]@users.noreply.github.com") - git("add", "pom.xml", "CHANGELOG.md") + git("add", "pom.xml", *[str(pom.relative_to(ROOT)) for pom in MODULE_POMS], "CHANGELOG.md") git("rm", "RELEASE.md") git("commit", "-m", f"Bump to version {new_version} and update changelog\n\n[skip ci]") git("tag", f"v{new_version}") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e06bbaf..fdf2a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,36 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: just coverage + test-jna: + name: "test hegel-jna (java ${{ matrix.java-version }})" + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + java-version: ["17", "21"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + cache: maven + + - uses: ./.github/actions/install-tools + with: + tools: just + + - name: Test hegel-jna with 100% coverage enforcement + env: + # Raises the GitHub API rate limit for fetch_natives.py asset discovery. + GITHUB_TOKEN: ${{ github.token }} + run: just coverage-jna + test-os: name: "test (${{ matrix.os }})" runs-on: ${{ matrix.os }} @@ -81,7 +111,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14] + os: [macos-14, windows-2025] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -129,7 +159,7 @@ jobs: release: name: release if: github.event_name == 'push' && github.repository == 'hegeldev/hegel-java' - needs: [lint, test, test-os, docs] + needs: [lint, test, test-jna, test-os, docs] runs-on: ubuntu-latest permissions: contents: write diff --git a/.gitignore b/.gitignore index 5a0e1d8..a2a8af8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,16 @@ target/ .hegel/ *.class *.pyc +CLAUDE.local.md +.claude/settings.local.json + +.vscode/ + +# whitelist .claude files, rather than blacklist. Some local claude files +# (skills, agents) have to live in .claude - as opposed to eg CLAUDE.local.md, +# which we gitignore at the top level. +.claude/* +!.claude/CLAUDE.md +!.claude/skills/ +.claude/skills/* +!.claude/skills/changelog/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ce69d38..e2bfb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,528 @@ # Changelog + + + + + + + + + + + + + + +## 0.10.0 - 2026-10-01 + +This release adds concurrent stateful testing. Running a state machine with +`Stateful.Options.maxConcurrency` above 1 makes the engine draw a concurrency level for each test +case and the driver run that many worker threads, which apply the machine's rules at the same time +on the same machine object, so races and lost updates surface as invariant failures like any other +bug: + +```java +class Counter { + private final Map store = new ConcurrentHashMap<>(); + private final AtomicInteger increments = new AtomicInteger(); + private final ConcurrentPool keys; + + Counter(TestCase tc) { + keys = new ConcurrentPool<>(tc); + } + + @Rule(group = "ops") + void register(TestCase tc) { + String key = tc.draw(text().minSize(1).maxSize(3)); + store.putIfAbsent(key, 0); + keys.add(tc, key); + } + + @Rule(group = "ops", weight = 3) + void increment(TestCase tc) { + String key = tc.draw(keys.reusable()); + store.put(key, store.get(key) + 1); // racy: a lost update + increments.incrementAndGet(); + } + + @Rule(group = "audit") + void audit(TestCase tc) { + tc.note("store holds " + store.size() + " keys"); + } + + @Invariant + void noLostUpdates(TestCase tc) { + assertEquals(increments.get(), store.values().stream().mapToInt(Integer::intValue).sum()); + } +} + +@HegelTest +void counterUnderContention(TestCase tc) { + Stateful.run(new Counter(tc), tc, Stateful.options().maxConcurrency(4)); +} +``` + +Execution proceeds in rounds. Each round the engine picks one concurrency group (`@Rule(group = +"...")`; rules that name none share the anonymous group), every worker applies a few of that group's +rules concurrently, and once all workers have finished the round the sampled invariants run on the +driving thread. Rules in the same group may therefore overlap in time, rules in different groups +never do, and invariants never overlap a rule. Rules run on the same machine object from several +threads, so its state must be safe for concurrent access; each rule receives its worker's own +`TestCase` and must draw only through it. The new `ConcurrentPool` is the thread-safe counterpart of +`Pool` for passing generated values between concurrent rules; its `add` takes the calling rule's test +case. A failure report groups each round's draws and notes by worker under the round's header, every +line stamped `[worker N +X.XXXms]` with the time since the machine started: + +``` +Concurrency level: 2 +---------------- Round 1: group "ops" ---------------- +[worker 0 +0.412ms] Rule: increment +[worker 0 +0.437ms] draw_1 = "a"; +[worker 1 +0.415ms] Rule: increment +[worker 1 +0.440ms] draw_2 = "a"; + +java.lang.AssertionError: expected: <2> but was: <1> +``` + +When a rule fails in one worker the other workers finish their round before the failure is +reported, and when several workers fail in the same round the lowest-numbered worker's exception is +reported with the others noted as dropped. A concurrency bug that depends on thread scheduling may +not reproduce on every replay; the engine then confirms it by repeated replay and reports it with a +caveat, as for any nondeterministic failure. + +`Stateful.options()` is the new way to configure a run: +`Stateful.run(machine, tc, Stateful.options().stepCount(30).minConcurrency(2).maxConcurrency(4))`. +The existing `run(machine, tc)` and `run(machine, tc, stepCount)` overloads are unchanged and keep +running the machine sequentially on the calling thread, with the same output and choice sequences +as before, so stored failures for existing stateful tests remain valid. + +In `dev.hegel:hegel-lowlevel`, `Libhegel` gains `testCaseClone` (`hegel_test_case_clone`; a draw, +so it returns the raw return code and reports `E_STOP_TEST` on a replay of a shorter sequence) and +`testCaseSetWorker` (`hegel_test_case_set_worker`). Bindings implementing `Libhegel` must add the +two methods, which is what makes this a minor release; the `dev.hegel:hegel` and +`dev.hegel:hegel-jna` frontends only gain API. +## 0.9.0 - 2026-09-28 + +This release upgrades the bundled libhegel engine from 0.43.4 to 0.44.0, which handles +nondeterministic tests instead of refusing them, and changes the `Failure` API accordingly. + +A test whose outcome changes when the same generated data is replayed — because it depends on hidden +global state, time, an outside service, or thread scheduling — previously aborted the run with a +`HegelException` reading "Flaky test detected". The engine now switches such a run to +nondeterministic handling: a discovered failure is confirmed by repeated replay before it is shrunk +or saved to the database, shrunk under a guard on how much reproduction reliability a shrink step may +trade away, and reported with a *caveat* quoting the run's own replay evidence. `Hegel.test` then +throws the test's own exception as for any other failure, and the printed report carries the caveat: + +``` +x = 11; +note: nondeterministic failure, confirmed: failed 7 of 20 replays at confirmation and 1 of 2 at report time + +To reproduce this failure, replay it with: + @HegelTest(reproduceFailure = "...") +``` + +A failure that never reproduces after its discovery still fails the run, reported as +`unconfirmed failure: failed 0 of 10 replays after the observed failure` and without a reproduce +blob. A reproduce blob from a nondeterministic failure records the failing runs the engine saw, and +`Settings.reproduceFailure` / `@HegelTest(reproduceFailure = ...)` now replays a blob until one of +its replays fails, under a bounded budget, instead of judging it stale after a single attempt; a +blob none of whose replays fail reports that "the supplied failure blob did not reproduce a failure". + +The new `Settings.nondeterminismStrictness` (and `@HegelTest(nondeterminismStrictness = ...)`) +controls the reaction: `NondeterminismStrictness.QUIET` (the engine's default) switches silently, +`WARN` prints a one-line notice once per run, and `ERROR` aborts the run with the previous +flaky-test error, for suites that use determinism as a lint. It is a profile setting like any other +(`nondeterminism_strictness = "error"` in `hegel.toml`), the `HEGEL_NONDETERMINISM_STRICTNESS` +environment variable (`quiet`, `warn` or `error`) overrides it for one run, and a value set in Java +wins over both. + +The engine now also runs every failure it is about to report one final time itself, so hegel-java no +longer replays reproduce blobs after a run: the reported draws, notes and exception come from the +engine's own final replay, and a failing test's body runs once less per reported failure. Two +visible consequences. `TestCase.isFinal()` is now true on every execution the engine stamps for the +failure report — the final replay of a counterexample, but also the short replays that confirm a +discovered failure — so a body that uses it to gate expensive diagnostics may run them a few times +per failure rather than once. And a `Reporter` receives the counterexample's `draw` and `note` +callbacks (flagged `finalReplay = true`) when the failure is reported, after the run loop, rather +than live while a replay executes; the order of callbacks is unchanged. + +`Failure` changes shape to carry the caveat and to admit failures without a blob: + +```java +// before +String blob = f.reproduceBlob(); +Optional e = f.exception(); +if (f.flaky()) { ... } + +// after +Optional blob = f.reproduceBlob(); // empty for an unconfirmed failure or a blob replay +Throwable e = f.exception(); // always present +Optional caveat = f.caveat(); // present for a nondeterministic failure +if (f.nondeterministic()) { ... } +``` + +`Failure.flaky()` is gone: the engine no longer hands back a failure whose replay passed. A +`Failure.reproduceBlob()` is also empty for a failure reproduced from a `reproduceFailure` blob, since +the caller already holds it. + +In `dev.hegel:hegel-lowlevel`, `Libhegel` gains `runStartBlob` (`hegel_run_start_blob`), +`testCaseShouldCapture` (`hegel_test_case_should_capture`), `failureCaveat` (`hegel_failure_caveat`) +and the `settingsNondeterminismStrictness` / `settingsGetNondeterminismStrictness` pair, and +`Abi.RUN_STATUS_FAILED_NONDETERMINISTIC` is removed along with the ABI value it mirrored; a failing +nondeterministic run reports plain `RUN_STATUS_FAILED`. Bindings implementing `Libhegel` must add +the new methods. + +Between 0.43.4 and 0.44.0 the engine also improved float generation, so tests on floats reach +overflow, underflow, cancellation and special-value bugs far more often (each test case draws its own +mixture of float categories, and bounded ranges cover every power-of-two scale they touch); roughly +halved the per-case cost of text generators built inside the test body by sharing built alphabets +between generators with the same constraints; shrinks a size drawn twice — the rows and columns of a +square matrix — while the values it governs still vary, reaching the smallest failing square; and +picked up correctness fixes in its arbitrary-precision integer dependency. +## 0.8.0 - 2026-09-28 + +This release upgrades the bundled libhegel engine from 0.42.4 to 0.43.4, adds rule weights to +stateful testing, and hands the `HEGEL_*` settings environment variables to the engine. + +A `@Rule` may now carry a weight, a hint about how often the engine should pick it relative to the +machine's other rules: + +```java +@Rule(weight = 5) +void get(TestCase tc) { ... } + +@Rule +void evictEverything(TestCase tc) { ... } +``` + +A plain `@Rule` has weight 1. The weight must be finite and strictly positive, and it is not a +distributional guarantee: each test case still enables a random subset of rules, so a rule's +realized frequency depends on which others are enabled alongside it. + +The engine now applies the `HEGEL_TEST_CASES`, `HEGEL_DATABASE`, `HEGEL_SEED`, `HEGEL_DERANDOMIZE`, +`HEGEL_PRINT_BLOB` and `HEGEL_STATISTICS` environment variables itself, so hegel-java reads them the +same way as every other Hegel frontend. They sit between the settings profile and the settings +written into a test: a variable wins over `hegel.toml` and the shipped profiles, and a value given +through `Settings` or `@HegelTest` wins over the variable. `HEGEL_TEST_CASES=10000 mvn test` runs +every test that does not set its own budget with 10000 cases; `@HegelTest(testCases = 5)` keeps +its 5. A malformed variable fails the run with an `IllegalArgumentException` naming it. + +This also fixes a gap in the previous release: hegel-java sent its own default of 100 test cases to +every run, so `test_cases` in a `hegel.toml` profile never took effect. Values left unset in Java +now genuinely fall through to the engine, and the `Settings` a `Reporter` receives in `runStarted` +carries the values the engine resolved. As a consequence the default for printing a reproduce blob +per failure now comes from the engine, whose shipped profiles turn it on: failing tests print a +`@HegelTest(reproduceFailure = "...")` line unless `Settings.printBlob(false)`, +`@HegelTest(printBlob = OptBoolean.FALSE)`, `HEGEL_PRINT_BLOB=false`, or a profile turns it off. +For the same reason, `@HegelTest.printBlob` is now an `OptBoolean` rather than a `boolean`: +replace `printBlob = true` with `printBlob = OptBoolean.TRUE`. `@HegelTest.testCases` defaults to +`0`, meaning the engine's value; any positive value behaves as before. + +hegel-java's own CI detection is gone. The engine selects its `ci` profile on the same servers and +does the same things (deterministic runs, database disabled), and additionally suppresses the +`TOO_SLOW` health check there; unlike the Java logic it replaces, it can be overridden by +`HEGEL_DERANDOMIZE`, `HEGEL_DATABASE`, or a `[profiles.ci]` section in `hegel.toml`. + +In `dev.hegel:hegel-lowlevel`, only code that implements or calls `Libhegel` directly is affected; +users of `dev.hegel:hegel` and `dev.hegel:hegel-jna` need not change anything beyond the annotation +attribute above. `Libhegel.newStateMachine` takes a new `double[] ruleWeights` argument after +`ruleGroups` (`null` for all-equal weights, the previous behaviour). `Libhegel.settingsNew` now +returns the raw result code and writes the handle to a `long[]` out-parameter, because constructing +the handle is where the engine applies the environment variables and can fail with +`Abi.E_INVALID_ARG`: + +```java +// before +long s = lib.settingsNew(); + +// after +long[] out = new long[1]; +int rc = lib.settingsNew(out); +``` + +`Libhegel` gains `settingsPrintBlob`, `settingsGetTestCases` and `settingsGetPrintBlob`, bound to +the engine's setter and getters of the same names. + +The engine's shrinker also improves in four situations (values that must stay equal to each other, +list elements whose deletion has to be paid for by a later draw, pairs of numbers bound by their +product, and integers whose failing values are sparse multiples), and opening a span is cheaper, +which lowers the per-draw overhead of every generator. +## 0.7.0 - 2026-09-25 + +This release upgrades the bundled libhegel engine from 0.37.6 to 0.42.4. The engine's shrinker now +reaches minimal examples in several situations where it previously stopped short (pairs of draws a +test pins together, `oneOf` alternatives that must switch to a shorter branch, failures that only +occur at multiples of a round number, bounded floats whose failing range excludes zero), a panic +during span reordering that could lose the shrunk counterexample is fixed, a memory leak in string +draws is fixed, and the limit on the number of choices a single test case may make rises from +8,192 to 1,048,576 (suppressing the `TEST_CASES_TOO_LARGE` health check removes it entirely). + +The only breaking changes are in `dev.hegel:hegel-lowlevel`, and only code that implements or calls +`Libhegel` directly is affected; users of `dev.hegel:hegel` and `dev.hegel:hegel-jna` need not +change anything. `Libhegel.newStateMachine` takes a new `stepCount` argument after the concurrency +bounds (the engine no longer has a default; 50 is the conventional choice), the `Abi.LABEL_*` and +`Abi.BACKEND_AUTO` constants are removed along with their engine counterparts, and +`Abi.VERBOSITY_NORMAL` and `Abi.VERBOSITY_QUIET` swap values (`NORMAL` is now 0). The verbosity +constants are inlined at compile time, so code built against 0.6.1 must be recompiled to send the +right value. + +Settings defaults are now resolved by the engine from named profiles, so hegel-java reads the same +`hegel.toml` as every other Hegel frontend: a `hegel.toml` in the working directory or one of its +parents (or the file named by `HEGEL_CONFIG`) applies to every run, and `HEGEL_DEFAULT_PROFILE` +selects the profile in effect. Settings given explicitly in Java, whether through `Settings` or +`@HegelTest`, still take precedence. `Backend.AUTO` now means exactly that the engine's profile +chooses: the shipped `workload` profile, selected inside Antithesis, uses `URANDOM`, and every other +profile uses `DEFAULT`. + +Span labels no longer carry meaning beyond identity: the engine treats two spans with the same +label as coming from the same generator and does nothing else with them. The `Label` constants are +now derived from `dev.hegel.` names with the same FNV-1a hash as `Label.of`, and the new +`Label.combine(long...)` derives the label of a generator built from other generators from its own +label and its components', matching the engine's `hegel_label_combine`, so that a list of integers +and a list of strings get different labels while every list of integers gets the same one: + +```java +long myList = Label.of("mylib.list"); +long myListOfIntegers = Label.combine(myList, Label.of("mylib.integers")); +``` + +The stateful step count is now a per-machine parameter rather than an engine default. `Stateful.run` +keeps using 50 steps per test case (`Stateful.DEFAULT_STEP_COUNT`), and a new +`Stateful.run(machine, tc, stepCount)` overload sets a different budget; each sampled invariant runs +with probability `1 / stepCount` after a step. +## 0.6.1 - 2026-09-16 + +Hegel can now be used as a library by other JVM frontends (hegeldev/hegel-java#12). + +- New `Hegel.run(body, settings, reporter)` returns a `RunReport` instead of throwing: the verdict + (`RunStatus`), per-outcome case counts (`RunStatistics`), the engine's message for an errored + run, and one `Failure` per distinct counterexample carrying the body's exception, the labelled + top-level draws of the minimal example as Java values, the notes, the engine's origin string, and + the reproduce blob. `RunReport.throwIfFailed()` reproduces `Hegel.test`'s throwing behaviour. +- New `Reporter` interface receives everything a run prints as callbacks (engine output, case + start/finish, final-replay draws and notes, each failure, the final report). Hegel no longer + writes to `System.err` directly: `Hegel.test` uses `Reporter.printing(System.err)`, which prints + exactly what it printed before, and `Reporter.silent()` turns output off. Both `Hegel.test` and + `Hegel.run` accept a reporter as an optional third argument. +- `Hegel.test` now returns the `RunReport` of a passed run. This is source-compatible (callers that + ignored the `void` result compile unchanged) but code compiled against an earlier release must be + recompiled. +- New `TestCase.isFinal()` tells a test body whether it is running the final replay of a + counterexample, where its own diagnostics will be seen. +- New `TestCase.span(label, body)` and public `Label` constants (plus `Label.of(name)` for minting + stable custom labels) let custom composite generators enclose their draws in a labelled span, so + the engine shrinks the structure as a unit. `TestCase.startSpan`/`stopSpan` are now documented. +- New `Settings.infrastructurePackages(prefixes...)` lists a frontend's own class-name prefixes so + they are skipped, like Hegel's and JUnit's, when locating the user frame a failure was thrown from. +- Reported draw names now follow the other Hegel frontends: a label drawn more than once in a case + is numbered from its second use (`x`, `x_2`, `x_3`) so no value is lost, and unlabelled draws are + numbered among themselves (`draw_1`, `draw_2`, ... — previously a labelled draw also advanced the + counter). A note made inside a composite generator is now reported after the enclosing draw's + value rather than before it. +- `Settings.verbosity` now governs the frontend's own output as documented: `QUIET` prints no + draws or notes, and `VERBOSE`/`DEBUG` report every case's draws and notes, not only the final + replay's. +- A checked exception thrown from a test body (possible from Kotlin, Clojure, and other frontends) + is now rethrown as-is instead of failing with a `ClassCastException`. + +New artifact `dev.hegel:hegel-lowlevel` (Java 17+), for people binding Hegel or building a frontend +from scratch. It holds only the binding contract: the `Libhegel` interface (one method per +`hegel_*` function, raw handles and return codes), the `Abi` constants, `LibraryLoader`, +`LibhegelException`, and the `LibhegelBackend` service provider interface. `Libhegel.load()` finds +whichever binding is on the classpath. Both `dev.hegel:hegel` and `dev.hegel:hegel-jna` now depend +on it and register their bindings as service providers; nothing changes for their users. The +package is marked experimental: implementors should expect new methods as the engine grows. +`HegelException` now extends `LibhegelException`. +## 0.6.0 - 2026-09-10 + +This release upgrades the bundled libhegel engine from 0.32.5 to 0.37.6. Along with engine-side +improvements to shrinking (stateful shrinking no longer keeps redundant steps and runs about 40% +faster; collections of expensive elements shrink further), the failure database (interrupting a +run mid-shrink can no longer lose a failure), and the health checks (`TooSlow` is suppressed by +default in CI, and every health check and the database are disabled inside Antithesis), it carries +three changes that may require updating your tests. + +**Single-test-case mode is gone.** The engine removed it, so `Mode`, `Settings.mode(Mode)`, and +`@HegelTest(mode = ...)` no longer exist. Every run drives the full property-test loop. To run +exactly one test case per invocation, set the test-case budget to 1 instead: the engine then skips +the simplest-example probe and generates one random case. + +Before: + +```java +@HegelTest(mode = Mode.SINGLE_TEST_CASE) +void probe(TestCase tc) { ... } +``` + +After: + +```java +@HegelTest(testCases = 1) +void probe(TestCase tc) { ... } +``` + +Unlike the old mode, a failing one-case run is still shrunk and replayed. + +**Stateful invariants are now sampled.** `@Invariant` methods previously ran after every rule. They +now run in full on the machine's initial and final state and are sampled in between: after any given +rule, each invariant runs with probability `1 / stepCount`, which keeps an invariant's expected cost +per test case constant as the step count grows. An invariant that must observe every intermediate +state — including one that mutates state when checked — can opt out of sampling with the new +`alwaysRun` attribute: + +```java +@Invariant(alwaysRun = true) +void noUnobservedWrites(TestCase tc) { + assertTrue(writesSinceLastCheck <= 1); + writesSinceLastCheck = 0; +} +``` + +The failure report's `Initial invariant check.` line is reworded to `Checking invariants on the +initial state.` (with a matching line for the final check), and is only printed for machines that +have invariants. + +**Times are nanosecond-precise.** `Generators.times()` and `Generators.datetimes()` now generate +and honour bounds at nanosecond rather than microsecond resolution. Bounds are no longer snapped to +whole microseconds, the default upper bound is `23:59:59.999999999`, and a lower bound of +`LocalTime.MAX`, which used to be rejected, is now a valid one-value range. +## 0.5.1 - 2026-08-28 + +This patch adds a second published artifact, `dev.hegel:hegel-jna`, which binds the native engine +over [JNA](https://github.com/java-native-access/jna) and runs on Java 17+. The existing +`dev.hegel:hegel` artifact is unchanged: it binds over the Foreign Function and Memory API and +requires Java 22+. + +Both artifacts expose the identical `dev.hegel` API and behave the same, so tests written against +one run unchanged against the other. Depend on exactly one of them — `hegel` on Java 22+, or +`hegel-jna` on older JVMs: + +```xml + + dev.hegel + hegel-jna + 0.5.1 + +``` + +`hegel-jna` pulls in `net.java.dev.jna:jna` as its only dependency. On JDK 24+ pass +`--enable-native-access=ALL-UNNAMED` to silence the JVM's native-access warning (the flag is +accepted on every supported JDK). +## 0.5.0 - 2026-08-28 + +This release upgrades the bundled libhegel engine from 0.14.14 to 0.32.5, rewriting the FFM +binding layer against the engine's modern typed-draw C ABI. It brings roughly two months of engine +correctness, shrinking, and performance improvements, plus several new features. + +New features: + +- **Stateful (model-based) testing.** Annotate methods of a state-machine class with `@Rule` and + `@Invariant` and drive it with `Stateful.run(machine, tc)`; the engine picks which action runs + next, and failing action sequences shrink like any other generated value. A `Pool` tracks + previously generated values so rules can reuse or consume them. + + ```java + class StackMachine { + private final Deque stack = new ArrayDeque<>(); + + @Rule + void push(TestCase tc) { + stack.push(tc.draw(integers())); + } + + @Rule + void pop(TestCase tc) { + tc.assume(!stack.isEmpty()); + stack.pop(); + } + + @Invariant + void neverNegative(TestCase tc) { + assertTrue(stack.size() >= 0); + } + } + + @HegelTest + void stackBehaves(TestCase tc) { + Stateful.run(new StackMachine(), tc); + } + ``` + +- **Failure reproduction blobs.** `new Settings().printBlob(true)` (or + `@HegelTest(printBlob = true)`) prints a copy-pasteable base64 blob with each reported failure; + `reproduceFailure("")` replays exactly that test case, bypassing generation and shrinking. + +- **Antithesis support.** `new Settings().backend(Backend.URANDOM)` sources every choice from + `/dev/urandom`, handing the [Antithesis](https://antithesis.com/) fuzzer control over the entire + test case. The default (`Backend.AUTO`) selects it automatically when running inside Antithesis. + +- **`allowSubnormal` on `floats()` and `doubles()`**, for testing code that may run with + flush-to-zero floating point (e.g. compiled with `-ffast-math`). + +- **Bounded temporal generators.** `dates()`, `times()`, and `datetimes()` accept inclusive + `min`/`max` bounds, and bounded dates shrink toward 2000-01-01. `domains()` gains + `maxLength(int)`. + +- **Engine output routing.** Engine-emitted output (verbose progress, warnings) now flows through + the same stream as the failing-example report instead of always going to stderr. + +Engine fixes picked up by the upgrade include: unbounded `doubles()` no longer returning +`Double.MAX_VALUE` most of the time, integer and string draws no longer being dominated by the +"interesting constants" pool, bounded values actually shrinking toward their target instead of 0, +regex anchors (`\b`, `\B`, `$`) respected in non-final positions, Unicode category filters covering +astral planes, several shrinker crashes and runaway-execution bugs, flaky tests reported as flaky +instead of under a wrong origin, and substantially more effective shrink passes. + +This release also fixes derandomized seeds in CI. A derandomized run derives its seed from the +test's database key, which hegel-java previously sent only when the example database was enabled — +and CI disables the database. Every named test in a CI run therefore derandomized from the same +fallback key and saw the same inputs. Each named test now derives its seed from its own name, so +repeated runs of one test stay deterministic while different tests still see different inputs. + +Breaking changes: + +- Custom `Generator` implementations must now implement `doDraw(TestCase)`; the CBOR schema + protocol (`asBasic()`/`BasicGenerator`) no longer exists, and `TestCase` exposes typed draw + bridges instead of `generateFromSchema`. Generators built purely from `Generators` factories and + combinators are unaffected. +- `text()` and `binary()` now default to a maximum size of 100 (or `minSize + 100` for larger + minimums) instead of unbounded, matching the other Hegel frontends; set an explicit `maxSize` for + longer values. +- A generator configuration the engine rejects (an empty text alphabet, an invalid regex) now + throws `IllegalArgumentException` carrying the engine's diagnostic instead of `HegelException`, + and conflicting float bound/special-value combinations are rejected at construction time. +- `reportMultipleFailures(true)` aggregates only when several distinct bugs are found; a run that + finds a single bug now rethrows it directly (preserving its type and stack trace) instead of + wrapping it in a one-entry "Hegel found 1 failing example" report, matching the other Hegel + frontends. +- The `com.upokecenter:cbor` dependency is gone. +## 0.4.2 - 2026-08-13 + +This patch adds Windows support (x86-64 and arm64). The jar now bundles the Windows engine alongside the Linux and macOS ones, so Hegel tests run on Windows with no extra setup. + +On Windows, a `libhegel.dll` placed on `PATH` takes precedence over the bundled engine (matching `LD_LIBRARY_PATH` on Linux and `DYLD_LIBRARY_PATH` on macOS), and the bundled engine is unpacked to a per-user cache under `%LOCALAPPDATA%`. `HEGEL_LIBHEGEL_PATH` overrides both, as on every OS. +## 0.4.1 - 2026-07-31 + +Fix error when cache directory for libhegel could not be written to, for example inside of a sandbox. +## 0.4.0 - 2026-07-09 + +This release changes the default value of `fullmatch` in `fromRegex` from `false` to `true`. +## 0.3.0 - 2026-06-29 + +Change the `Generators.uuids()` return type from `String` to `java.util.UUID`, and expose version configuration as a `uuids().version(v)` method. +## 0.2.0 - 2026-06-26 + +Improve Java Platform Module System support: + +- Define `Automatic-Module-Name: dev.hegel` in the jar manifest, giving the artifact a stable + module name on the module path. +- `@HegelTest` now invokes the test method through the JUnit platform's reflection support, so + modular consumers no longer have to open their test package to `dev.hegel`. +## 0.1.1 - 2026-06-12 + +This release fixes the display of our published javadocs to include package info, and has no other functional changes. ## 0.1.0 - 2026-06-10 Initial release. diff --git a/README.md b/README.md index 5442985..0d28b88 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,21 @@ Instead of writing tests with hand-picked example inputs, you describe a *proper ## Installation +Hegel for Java ships as two interchangeable artifacts with the same API — pick the one that +matches your JVM: + +- **`dev.hegel:hegel`** — requires **Java 22+**; binds the engine over the + [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html) + with no extra dependencies. +- **`dev.hegel:hegel-jna`** — requires **Java 17+**; binds the engine over + [JNA](https://github.com/java-native-access/jna). + Add the dependency with Maven: ```xml dev.hegel - hegel + hegel 0.1.0 test @@ -30,12 +39,19 @@ Add the dependency with Maven: or with Gradle: ```kotlin -testImplementation("dev.hegel:hegel:0.1.0") +testImplementation("dev.hegel:hegel:0.1.0") // or "dev.hegel:hegel-jna:0.1.0" ``` -Hegel for Java requires **Java 22+** and uses the [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html). The native engine is bundled in the jar for Linux (x86-64 and arm64) and macOS (Apple Silicon). +Depend on exactly one of the two — they contain the same classes and differ only in how they call +the native engine. The engine is bundled in both jars for Linux (x86-64 and arm64), macOS (Apple +Silicon), and Windows (x86-64 and arm64). Both pull in a third, small artifact, +`dev.hegel:hegel-lowlevel`, which holds the binding contract; you never need to depend on it +directly unless you are [binding Hegel yourself](#binding-hegel-yourself). -Because Hegel calls native code, pass `--enable-native-access=ALL-UNNAMED` to silence the JVM's native-access warning. With Maven Surefire: +Because Hegel calls native code, pass `--enable-native-access=ALL-UNNAMED` to silence the JVM's +native-access warning — printed by JDK 22+ for `hegel` (FFM) and by JDK 24+ for `hegel-jna` (JNA, +under [JEP 472](https://openjdk.org/jeps/472)). The flag is accepted on every supported JDK +(17+), so it is safe to set unconditionally. With Maven Surefire: ```xml --enable-native-access=ALL-UNNAMED @@ -80,3 +96,68 @@ org.opentest4j.AssertionFailedError: expected: <2> but was: <1> Hegel reports the minimal example showing that our sort is incorrectly dropping duplicates: `[0, 0]`, two equal elements, which `mySort` collapses into one. If we replace the `TreeSet`-based body of `mySort()` with a sort that keeps duplicates, this test will then pass. The optional `"xs"` label passed to `draw` names the value in the falsifying-example output. See the [API documentation](https://javadoc.io/doc/dev.hegel/hegel) for a full tour of generators, combinators, control functions, and settings. + +## Stateful testing + +Model-based tests drive a state machine whose `@Rule` methods are the actions and whose `@Invariant` +methods are the properties, with the engine choosing and shrinking the action sequence: + +```java +Stateful.run(new IntegerStack(), tc); +``` + +Run the same machine with a concurrency bound to look for races: the engine draws how many worker +threads apply the rules at once, `@Rule(group = "...")` says which rules may overlap, and a +`ConcurrentPool` passes generated values between concurrent rules. + +```java +Stateful.run(new Counter(tc), tc, Stateful.options().maxConcurrency(4)); +``` + +See the `Stateful` Javadoc for complete sequential and concurrent examples. The concurrent one, a +counter that loses updates under contention, is checked in as a runnable demonstration that is +skipped unless asked for; run it from a checkout with + +```bash +mvn test -pl hegel -am -Dtest=ConcurrentCounterExample -Dhegel.examples=true -Dsurefire.failIfNoSpecifiedTests=false +``` + +(or `-pl hegel-jna -am` for the JNA frontend) and it fails with a report of the rounds and the +worker-stamped rule and draw lines that led to the lost update. + +## Using Hegel as a library + +Frontends for other JVM languages (or custom runners) use `Hegel.run` instead of `Hegel.test`. It +returns a `RunReport` rather than throwing, and a `Reporter` lets you own every line of output: + +```java +RunReport report = Hegel.run(tc -> { ... }, new Settings().testCases(200), Reporter.silent()); +report.status(); // PASSED, FAILED, or ERROR +report.statistics(); // valid / invalid / overrun / interesting case counts +for (Failure f : report.failures()) { // one per distinct counterexample + f.draws(); // labelled draws of the minimal example, as Java values + f.exception(); // the body's own throwable + f.reproduceBlob(); // Optional: replay it later with Settings.reproduceFailure + f.caveat(); // Optional: how reliably a nondeterministic failure reproduced +} +``` + +`TestCase.isFinal()` identifies the executions the engine stamped for the failure report (the +final replay of a counterexample among them), `TestCase.span` and `Label` let custom composite +generators tell the engine about their structure, and `Settings.infrastructurePackages` keeps a +frontend's own stack frames out of failure origins. + +## Binding Hegel yourself + +`dev.hegel:hegel-lowlevel` (Java 17+, no dependencies) is the binding contract on its own, for two +audiences that do not want the Java frontend: + +- **Writing a binding** — implement `dev.hegel.lowlevel.Libhegel` (one method per `hegel_*` + function in `hegel.h`, with raw handles and return codes) over your FFI mechanism, and register + it as a `dev.hegel.lowlevel.LibhegelBackend` service provider. The two bundled bindings are + registered the same way. +- **Building a frontend from scratch** — depend on `hegel-lowlevel` plus one binding, call + `Libhegel.load()` to get the engine, and drive the run loop and per-case primitives yourself. + `Abi` holds the constants; `LibraryLoader` resolves the shared library. + +The package is experimental: new engine functions become new `Libhegel` methods. diff --git a/RELEASE-sample.md b/RELEASE-sample.md index 36b6f0f..b0db7cd 100644 --- a/RELEASE-sample.md +++ b/RELEASE-sample.md @@ -5,8 +5,8 @@ A short, user-facing description of the change, written for the CHANGELOG. Copy this file to `RELEASE.md` in any pull request that changes source. The first line must be `RELEASE_TYPE: major`, `RELEASE_TYPE: minor`, or `RELEASE_TYPE: patch`: -- `patch` — bug fixes -- `minor` — new, backwards-compatible features -- `major` — breaking changes (maintainers only) +- `patch` — bug fixes, internal changes, and new non-breaking features (the default) +- `minor` — breaking changes only +- `major` — not used while we are pre-1.0 (zerover); reserved for the eventual 1.0 Label a PR `skip release` to bypass this requirement (e.g. for docs/CI-only changes). diff --git a/hegel-jna/pom.xml b/hegel-jna/pom.xml new file mode 100644 index 0000000..cb7e6a0 --- /dev/null +++ b/hegel-jna/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + dev.hegel + hegel-parent + 0.10.0 + + + hegel-jna + jar + + hegel-java (JNA) + Property-based testing for Java, built on Hypothesis (JNA binding, Java 17+) + + + 17 + + + + + dev.hegel + hegel-lowlevel + ${project.version} + + + net.java.dev.jna + jna + 5.17.0 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + dev.hegel.jna + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + org.codehaus.mojo + exec-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + com.diffplug.spotless + spotless-maven-plugin + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + org.jacoco + jacoco-maven-plugin + + + + diff --git a/hegel-jna/src/main/java/dev/hegel/JnaBackend.java b/hegel-jna/src/main/java/dev/hegel/JnaBackend.java new file mode 100644 index 0000000..46fe0af --- /dev/null +++ b/hegel-jna/src/main/java/dev/hegel/JnaBackend.java @@ -0,0 +1,21 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Libhegel; +import dev.hegel.lowlevel.LibhegelBackend; +import java.nio.file.Path; + +/** + * The JNA binding, registered as a {@link LibhegelBackend} service provider so {@link + * Libhegel#load()} finds it on the classpath. + * + * @hidden + */ +public final class JnaBackend implements LibhegelBackend { + /** Public no-argument constructor, as {@link java.util.ServiceLoader} requires. */ + public JnaBackend() {} + + @Override + public Libhegel open(Path library) { + return new JnaLibhegel(library); + } +} diff --git a/hegel-jna/src/main/java/dev/hegel/JnaLibhegel.java b/hegel-jna/src/main/java/dev/hegel/JnaLibhegel.java new file mode 100644 index 0000000..a3cd87f --- /dev/null +++ b/hegel-jna/src/main/java/dev/hegel/JnaLibhegel.java @@ -0,0 +1,1084 @@ +package dev.hegel; + +import com.sun.jna.Callback; +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.StringArray; +import com.sun.jna.Structure; +import com.sun.jna.ptr.ByteByReference; +import com.sun.jna.ptr.DoubleByReference; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.LongByReference; +import com.sun.jna.ptr.PointerByReference; +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.Libhegel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * The real libhegel binding, driving the C ABI over JNA (Java Native Access), for JVMs without the + * Foreign Function and Memory API (Java 17+; the {@code hegel} artifact binds via FFM on 22+). + * + *

Every fallible call takes a {@code hegel_context_t*} as its first argument, on which libhegel + * records the diagnostic of a failed call. A context must not be shared between threads, so each + * thread lazily creates its own; {@link #lastErrorMessage()} reads the current thread's context, + * which is what the failing call just wrote. Contexts are never freed — one small allocation per + * thread that touches the engine, for the life of the process. + */ +final class JnaLibhegel implements Libhegel { + // hegel_run_start registers the output callback with the engine until hegel_run_free, so a + // strong reference is held per run (JNA frees the native thunk when the Callback is collected). + private final Map runCallbacks = new ConcurrentHashMap<>(); + + private final HegelNative lib; + private final ThreadLocal context; + + JnaLibhegel(Path libraryPath) { + try { + this.lib = Native.load( + libraryPath.toString(), HegelNative.class, Map.of(Library.OPTION_STRING_ENCODING, "UTF-8")); + } catch (UnsatisfiedLinkError e) { + throw new HegelException("Failed to open libhegel at " + libraryPath + ": " + e.getMessage()); + } + this.context = ThreadLocal.withInitial(lib::hegel_context_new); + } + + interface HegelNative extends Library { + Pointer hegel_context_new(); + + Pointer hegel_context_last_error(Pointer ctx); + + int hegel_settings_new(Pointer ctx, PointerByReference out); + + int hegel_settings_free(Pointer ctx, Pointer s); + + int hegel_settings_set_backend(Pointer ctx, Pointer s, int backend); + + int hegel_settings_set_test_cases(Pointer ctx, Pointer s, long n); + + int hegel_settings_set_verbosity(Pointer ctx, Pointer s, int v); + + int hegel_settings_set_seed(Pointer ctx, Pointer s, long seed, byte hasSeed); + + int hegel_settings_set_derandomize(Pointer ctx, Pointer s, byte derandomize); + + int hegel_settings_set_report_multiple_failures(Pointer ctx, Pointer s, byte yes); + + int hegel_settings_set_database(Pointer ctx, Pointer s, String path); + + int hegel_settings_set_database_key(Pointer ctx, Pointer s, String key); + + int hegel_settings_set_phases(Pointer ctx, Pointer s, int mask); + + int hegel_settings_set_suppress_health_check(Pointer ctx, Pointer s, int mask); + + int hegel_settings_set_print_blob(Pointer ctx, Pointer s, byte yes); + + int hegel_settings_get_test_cases(Pointer ctx, Pointer s, LongByReference out); + + int hegel_settings_get_print_blob(Pointer ctx, Pointer s, ByteByReference out); + + int hegel_settings_set_nondeterminism_strictness(Pointer ctx, Pointer s, int strictness); + + int hegel_settings_get_nondeterminism_strictness(Pointer ctx, Pointer s, IntByReference out); + + int hegel_run_start( + Pointer ctx, Pointer settings, LineCallback callback, Pointer userData, PointerByReference out); + + int hegel_run_start_blob( + Pointer ctx, + Pointer settings, + String blob, + LineCallback callback, + Pointer userData, + PointerByReference out); + + int hegel_next_test_case(Pointer ctx, Pointer run, PointerByReference out); + + int hegel_run_result(Pointer ctx, Pointer run, PointerByReference out); + + int hegel_run_result_free(Pointer ctx, Pointer result); + + int hegel_run_free(Pointer ctx, Pointer run); + + int hegel_test_case_from_blob( + Pointer ctx, + Pointer settings, + String blob, + LineCallback callback, + Pointer userData, + PointerByReference out); + + int hegel_test_case_free(Pointer ctx, Pointer tc); + + int hegel_test_case_should_capture(Pointer ctx, Pointer tc, ByteByReference out); + + int hegel_test_case_clone(Pointer ctx, Pointer tc, PointerByReference out); + + int hegel_test_case_set_worker(Pointer ctx, Pointer tc, long workerIndex); + + int hegel_generate_boolean(Pointer ctx, Pointer tc, double p, byte hasForced, byte forced, ByteByReference out); + + int hegel_generate_integer(Pointer ctx, Pointer tc, long min, long max, LongByReference out); + + int hegel_generate_float( + Pointer ctx, + Pointer tc, + int width, + double min, + double max, + byte allowNan, + byte allowInfinity, + byte excludeMin, + byte excludeMax, + double smallestNonzeroMagnitude, + DoubleByReference out); + + int hegel_generate_bytes(Pointer ctx, Pointer tc, long minSize, long maxSize, BufferResult out); + + int hegel_generate_bytes_result_free(Pointer ctx, BufferResult result); + + int hegel_generate_string(Pointer ctx, Pointer tc, Pointer generator, BufferResult out); + + int hegel_generate_string_result_free(Pointer ctx, BufferResult result); + + int hegel_generate_date(Pointer ctx, Pointer tc, HegelDate.ByValue min, HegelDate.ByValue max, HegelDate out); + + int hegel_generate_time(Pointer ctx, Pointer tc, HegelTime.ByValue min, HegelTime.ByValue max, HegelTime out); + + int hegel_generate_datetime( + Pointer ctx, Pointer tc, HegelDatetime.ByValue min, HegelDatetime.ByValue max, HegelDatetime out); + + int hegel_generate_uuid(Pointer ctx, Pointer tc, byte version, byte hasVersion, Pointer out); + + int hegel_generate_ipv4(Pointer ctx, Pointer tc, Pointer out); + + int hegel_generate_ipv6(Pointer ctx, Pointer tc, Pointer out); + + int hegel_string_generator_text( + Pointer ctx, + long minSize, + long maxSize, + String codec, + int minCodepoint, + int maxCodepoint, + Pointer categories, + long categoriesLen, + Pointer excludeCategories, + long excludeCategoriesLen, + Pointer includeCharacters, + long includeCharactersLen, + Pointer excludeCharacters, + long excludeCharactersLen, + PointerByReference out); + + int hegel_string_generator_regex( + Pointer ctx, String pattern, byte fullmatch, Pointer alphabet, PointerByReference out); + + int hegel_string_generator_email(Pointer ctx, PointerByReference out); + + int hegel_string_generator_url(Pointer ctx, PointerByReference out); + + int hegel_string_generator_domain(Pointer ctx, long maxLength, PointerByReference out); + + int hegel_string_generator_free(Pointer ctx, Pointer generator); + + int hegel_start_span(Pointer ctx, Pointer tc, long label); + + int hegel_stop_span(Pointer ctx, Pointer tc, byte discard); + + int hegel_new_collection(Pointer ctx, Pointer tc, long minSize, long maxSize, LongByReference out); + + int hegel_collection_more(Pointer ctx, Pointer tc, long id, ByteByReference out); + + int hegel_collection_reject(Pointer ctx, Pointer tc, long id, String why); + + int hegel_new_pool(Pointer ctx, Pointer tc, LongByReference out); + + int hegel_pool_add(Pointer ctx, Pointer tc, long poolId, LongByReference out); + + int hegel_pool_generate(Pointer ctx, Pointer tc, long poolId, byte consume, LongByReference out); + + // State-machine handles cross as raw addresses (long), like every other opaque handle. + int hegel_new_state_machine( + Pointer ctx, + Pointer tc, + Pointer ruleNames, + Pointer ruleGroups, + Pointer ruleWeights, + long ruleNamesLen, + Pointer invariantNames, + Pointer invariantAlwaysCheck, + long invariantNamesLen, + long minConcurrency, + long maxConcurrency, + long stepCount, + LongByReference outStateMachine, + LongByReference outConcurrency); + + int hegel_state_machine_next_group(Pointer ctx, Pointer tc, long stateMachineId, LongByReference out); + + int hegel_state_machine_next_rule( + Pointer ctx, Pointer tc, long stateMachineId, long workerIndex, LongByReference out); + + int hegel_state_machine_rule_rejected(Pointer ctx, Pointer tc, long stateMachineId, long workerIndex); + + int hegel_state_machine_should_check_invariant( + Pointer ctx, Pointer tc, long stateMachineId, long invariantIndex, ByteByReference out); + + int hegel_state_machine_free(Pointer ctx, long stateMachineId); + + int hegel_target(Pointer ctx, Pointer tc, double value, String label); + + int hegel_mark_complete(Pointer ctx, Pointer tc, int status, String origin); + + int hegel_run_result_status(Pointer ctx, Pointer result, IntByReference out); + + int hegel_run_result_error(Pointer ctx, Pointer result, PointerByReference out); + + int hegel_run_result_failure_count(Pointer ctx, Pointer result, LongByReference out); + + int hegel_run_result_failure(Pointer ctx, Pointer result, long index, PointerByReference out); + + int hegel_failure_free(Pointer ctx, Pointer failure); + + int hegel_failure_reproduction_blob(Pointer ctx, Pointer failure, PointerByReference out); + + int hegel_failure_origin(Pointer ctx, Pointer failure, PointerByReference out); + + int hegel_failure_caveat(Pointer ctx, Pointer failure, PointerByReference out); + + int hegel_version(Pointer ctx, PointerByReference out); + } + + /** {@code struct hegel_date_t { int32_t year; uint8_t month; uint8_t day; }} */ + @Structure.FieldOrder({"year", "month", "day"}) + public static class HegelDate extends Structure { + public int year; + public byte month; + public byte day; + + public static class ByValue extends HegelDate implements Structure.ByValue {} + } + + /** {@code struct hegel_time_t { uint8_t hour; uint8_t minute; uint8_t second; uint32_t nanosecond; }} */ + @Structure.FieldOrder({"hour", "minute", "second", "nanosecond"}) + public static class HegelTime extends Structure { + public byte hour; + public byte minute; + public byte second; + public int nanosecond; + + public static class ByValue extends HegelTime implements Structure.ByValue {} + } + + /** {@code struct hegel_datetime_t { hegel_date_t date; hegel_time_t time; }} */ + @Structure.FieldOrder({"date", "time"}) + public static class HegelDatetime extends Structure { + public HegelDate date; + public HegelTime time; + + public static class ByValue extends HegelDatetime implements Structure.ByValue {} + } + + /** {@code struct hegel_generate_bytes_result_t / hegel_generate_string_result_t { T* data; size_t len; }} */ + @Structure.FieldOrder({"data", "len"}) + public static class BufferResult extends Structure { + public Pointer data; + public long len; + } + + interface OutputCallback extends Callback { + void invoke(Pointer userData, Pointer line, long len); + } + + /** + * Bridges engine output lines to the run's {@link Consumer}. A named class rather than a + * lambda so JNA can reliably resolve the callback method reflectively. + */ + static final class LineCallback implements OutputCallback { + private final Consumer output; + + LineCallback(Consumer output) { + this.output = output; + } + + @Override + public void invoke(Pointer userData, Pointer line, long len) { + emitLine(output, line, len); + } + } + + /** + * Bridge one line of engine output to the run's {@link Consumer}. An exception escaping a + * native callback must never unwind into the engine, so every throwable is swallowed here. + */ + static void emitLine(Consumer output, Pointer line, long len) { + try { + byte[] bytes = line.getByteArray(0, (int) len); + output.accept(new String(bytes, StandardCharsets.UTF_8)); + } catch (Throwable t) { + // Deliberately dropped: output delivery must never unwind into the engine. + } + } + + private Pointer ctx() { + return context.get(); + } + + private static Pointer pointer(long handle) { + return handle == 0 ? null : new Pointer(handle); + } + + private static long address(Pointer p) { + return Pointer.nativeValue(p); + } + + private static byte cbool(boolean value) { + return (byte) (value ? 1 : 0); + } + + private void check(String op, int code) { + if (code != Abi.OK) { + throw new HegelException( + op + " failed (rc=" + code + "): " + java.util.Objects.toString(lastErrorMessage(), "")); + } + } + + static String readCString(Pointer ptr) { + return ptr == null ? null : ptr.getString(0, "UTF-8"); + } + + // --- settings --- + + @Override + public int settingsNew(long[] out) { + // The reference starts out NULL and the engine writes it only on success, so a failed call + // reports 0 without a branch of its own. + PointerByReference handle = new PointerByReference(); + int code = lib.hegel_settings_new(ctx(), handle); + out[0] = address(handle.getValue()); + return code; + } + + @Override + public void settingsFree(long s) { + check("hegel_settings_free", lib.hegel_settings_free(ctx(), pointer(s))); + } + + @Override + public void settingsBackend(long s, int backend) { + check("hegel_settings_set_backend", lib.hegel_settings_set_backend(ctx(), pointer(s), backend)); + } + + @Override + public void settingsTestCases(long s, long n) { + check("hegel_settings_set_test_cases", lib.hegel_settings_set_test_cases(ctx(), pointer(s), n)); + } + + @Override + public void settingsVerbosity(long s, int v) { + check("hegel_settings_set_verbosity", lib.hegel_settings_set_verbosity(ctx(), pointer(s), v)); + } + + @Override + public void settingsSeed(long s, long seed, boolean hasSeed) { + check("hegel_settings_set_seed", lib.hegel_settings_set_seed(ctx(), pointer(s), seed, cbool(hasSeed))); + } + + @Override + public void settingsDerandomize(long s, boolean derandomize) { + check( + "hegel_settings_set_derandomize", + lib.hegel_settings_set_derandomize(ctx(), pointer(s), cbool(derandomize))); + } + + @Override + public void settingsReportMultipleFailures(long s, boolean yes) { + check( + "hegel_settings_set_report_multiple_failures", + lib.hegel_settings_set_report_multiple_failures(ctx(), pointer(s), cbool(yes))); + } + + @Override + public void settingsDatabase(long s, String path) { + check("hegel_settings_set_database", lib.hegel_settings_set_database(ctx(), pointer(s), path)); + } + + @Override + public void settingsDatabaseKey(long s, String key) { + check("hegel_settings_set_database_key", lib.hegel_settings_set_database_key(ctx(), pointer(s), key)); + } + + @Override + public void settingsPhases(long s, int mask) { + check("hegel_settings_set_phases", lib.hegel_settings_set_phases(ctx(), pointer(s), mask)); + } + + @Override + public void settingsSuppressHealthCheck(long s, int mask) { + check( + "hegel_settings_set_suppress_health_check", + lib.hegel_settings_set_suppress_health_check(ctx(), pointer(s), mask)); + } + + @Override + public void settingsPrintBlob(long s, boolean yes) { + check("hegel_settings_set_print_blob", lib.hegel_settings_set_print_blob(ctx(), pointer(s), cbool(yes))); + } + + @Override + public long settingsGetTestCases(long s) { + LongByReference out = new LongByReference(); + check("hegel_settings_get_test_cases", lib.hegel_settings_get_test_cases(ctx(), pointer(s), out)); + return out.getValue(); + } + + @Override + public boolean settingsGetPrintBlob(long s) { + ByteByReference out = new ByteByReference(); + check("hegel_settings_get_print_blob", lib.hegel_settings_get_print_blob(ctx(), pointer(s), out)); + return out.getValue() != 0; + } + + @Override + public void settingsNondeterminismStrictness(long s, int strictness) { + check( + "hegel_settings_set_nondeterminism_strictness", + lib.hegel_settings_set_nondeterminism_strictness(ctx(), pointer(s), strictness)); + } + + @Override + public int settingsGetNondeterminismStrictness(long s) { + IntByReference out = new IntByReference(); + check( + "hegel_settings_get_nondeterminism_strictness", + lib.hegel_settings_get_nondeterminism_strictness(ctx(), pointer(s), out)); + return out.getValue(); + } + + // --- run lifecycle --- + + @Override + public long runStart(long settings, Consumer output) { + LineCallback callback = output == null ? null : new LineCallback(output); + PointerByReference out = new PointerByReference(); + int code = lib.hegel_run_start(ctx(), pointer(settings), callback, null, out); + if (code != Abi.OK) { + throw new HegelException( + "hegel_run_start failed (rc=" + code + "): " + java.util.Objects.toString(lastErrorMessage(), "")); + } + long run = address(out.getValue()); + if (callback != null) { + runCallbacks.put(run, callback); + } + return run; + } + + @Override + public long runStartBlob(long settings, String blob, Consumer output) { + LineCallback callback = output == null ? null : new LineCallback(output); + PointerByReference out = new PointerByReference(); + int code = lib.hegel_run_start_blob(ctx(), pointer(settings), blob, callback, null, out); + if (code != Abi.OK) { + throw new HegelException("hegel_run_start_blob failed (rc=" + + code + + "): " + + java.util.Objects.toString(lastErrorMessage(), "")); + } + long run = address(out.getValue()); + if (callback != null) { + runCallbacks.put(run, callback); + } + return run; + } + + @Override + public long nextTestCase(long run) { + PointerByReference out = new PointerByReference(); + check("hegel_next_test_case", lib.hegel_next_test_case(ctx(), pointer(run), out)); + return address(out.getValue()); + } + + @Override + public long runResult(long run) { + PointerByReference out = new PointerByReference(); + check("hegel_run_result", lib.hegel_run_result(ctx(), pointer(run), out)); + return address(out.getValue()); + } + + @Override + public void runResultFree(long result) { + check("hegel_run_result_free", lib.hegel_run_result_free(ctx(), pointer(result))); + } + + @Override + public void runFree(long run) { + check("hegel_run_free", lib.hegel_run_free(ctx(), pointer(run))); + runCallbacks.remove(run); + } + + @Override + public int testCaseFromBlob(long settings, String blob, Consumer output, long[] out) { + // The blob replay's output is emitted synchronously during this call, so the callback only + // needs to live for its duration. + LineCallback callback = output == null ? null : new LineCallback(output); + PointerByReference outRef = new PointerByReference(); + int code = lib.hegel_test_case_from_blob(ctx(), pointer(settings), blob, callback, null, outRef); + if (code == Abi.OK) { + out[0] = address(outRef.getValue()); + } + return code; + } + + @Override + public void testCaseFree(long tc) { + check("hegel_test_case_free", lib.hegel_test_case_free(ctx(), pointer(tc))); + } + + @Override + public boolean testCaseShouldCapture(long tc) { + ByteByReference out = new ByteByReference(); + check("hegel_test_case_should_capture", lib.hegel_test_case_should_capture(ctx(), pointer(tc), out)); + return out.getValue() != 0; + } + + @Override + public int testCaseClone(long tc, long[] out) { + PointerByReference ref = new PointerByReference(); + int code = lib.hegel_test_case_clone(ctx(), pointer(tc), ref); + if (code == Abi.OK) { + out[0] = address(ref.getValue()); + } + return code; + } + + @Override + public void testCaseSetWorker(long tc, long workerIndex) { + check("hegel_test_case_set_worker", lib.hegel_test_case_set_worker(ctx(), pointer(tc), workerIndex)); + } + + // --- draws --- + + @Override + public int generateBoolean(long tc, double p, boolean[] out) { + ByteByReference ref = new ByteByReference(); + int code = lib.hegel_generate_boolean(ctx(), pointer(tc), p, cbool(false), cbool(false), ref); + if (code == Abi.OK) { + out[0] = ref.getValue() != 0; + } + return code; + } + + @Override + public int generateInteger(long tc, long min, long max, long[] out) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_generate_integer(ctx(), pointer(tc), min, max, ref); + if (code == Abi.OK) { + out[0] = ref.getValue(); + } + return code; + } + + @Override + public int generateFloat( + long tc, + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude, + double[] out) { + DoubleByReference ref = new DoubleByReference(); + int code = lib.hegel_generate_float( + ctx(), + pointer(tc), + width, + min, + max, + cbool(allowNan), + cbool(allowInfinity), + cbool(excludeMin), + cbool(excludeMax), + smallestNonzeroMagnitude, + ref); + if (code == Abi.OK) { + out[0] = ref.getValue(); + } + return code; + } + + @Override + public int generateBytes(long tc, long minSize, long maxSize, byte[][] out) { + BufferResult result = new BufferResult(); + int code = lib.hegel_generate_bytes(ctx(), pointer(tc), minSize, maxSize, result); + if (code == Abi.OK) { + out[0] = copyBuffer(result); + check("hegel_generate_bytes_result_free", lib.hegel_generate_bytes_result_free(ctx(), result)); + } + return code; + } + + @Override + public int generateString(long tc, long generator, String[] out) { + BufferResult result = new BufferResult(); + int code = lib.hegel_generate_string(ctx(), pointer(tc), pointer(generator), result); + if (code == Abi.OK) { + out[0] = new String(copyBuffer(result), StandardCharsets.UTF_8); + check("hegel_generate_string_result_free", lib.hegel_generate_string_result_free(ctx(), result)); + } + return code; + } + + /** Copy an engine-allocated {@code {data, len}} buffer out before it is freed. */ + private static byte[] copyBuffer(BufferResult result) { + return result.data.getByteArray(0, (int) result.len); + } + + @Override + public int generateDate(long tc, LocalDate min, LocalDate max, LocalDate[] out) { + HegelDate result = new HegelDate(); + int code = lib.hegel_generate_date(ctx(), pointer(tc), dateValue(min), dateValue(max), result); + if (code == Abi.OK) { + out[0] = readDate(result); + } + return code; + } + + @Override + public int generateTime(long tc, LocalTime min, LocalTime max, LocalTime[] out) { + HegelTime result = new HegelTime(); + int code = lib.hegel_generate_time(ctx(), pointer(tc), timeValue(min), timeValue(max), result); + if (code == Abi.OK) { + out[0] = readTime(result); + } + return code; + } + + @Override + public int generateDatetime(long tc, LocalDateTime min, LocalDateTime max, LocalDateTime[] out) { + HegelDatetime result = new HegelDatetime(); + int code = lib.hegel_generate_datetime(ctx(), pointer(tc), datetimeValue(min), datetimeValue(max), result); + if (code == Abi.OK) { + out[0] = LocalDateTime.of(readDate(result.date), readTime(result.time)); + } + return code; + } + + private static HegelDate.ByValue dateValue(LocalDate date) { + HegelDate.ByValue value = new HegelDate.ByValue(); + fillDate(value, date); + return value; + } + + private static HegelTime.ByValue timeValue(LocalTime time) { + HegelTime.ByValue value = new HegelTime.ByValue(); + fillTime(value, time); + return value; + } + + private static HegelDatetime.ByValue datetimeValue(LocalDateTime dt) { + HegelDatetime.ByValue value = new HegelDatetime.ByValue(); + value.date = new HegelDate(); + value.time = new HegelTime(); + fillDate(value.date, dt.toLocalDate()); + fillTime(value.time, dt.toLocalTime()); + return value; + } + + private static void fillDate(HegelDate value, LocalDate date) { + value.year = date.getYear(); + value.month = (byte) date.getMonthValue(); + value.day = (byte) date.getDayOfMonth(); + } + + private static LocalDate readDate(HegelDate value) { + return LocalDate.of(value.year, value.month, value.day); + } + + private static void fillTime(HegelTime value, LocalTime time) { + value.hour = (byte) time.getHour(); + value.minute = (byte) time.getMinute(); + value.second = (byte) time.getSecond(); + value.nanosecond = time.getNano(); + } + + private static LocalTime readTime(HegelTime value) { + return LocalTime.of(value.hour, value.minute, value.second, value.nanosecond); + } + + @Override + public int generateUuid(long tc, int version, boolean hasVersion, byte[] out16) { + return fixedBytesDraw( + buf -> lib.hegel_generate_uuid(ctx(), pointer(tc), (byte) version, cbool(hasVersion), buf), out16); + } + + @Override + public int generateIpv4(long tc, byte[] out4) { + return fixedBytesDraw(buf -> lib.hegel_generate_ipv4(ctx(), pointer(tc), buf), out4); + } + + @Override + public int generateIpv6(long tc, byte[] out16) { + return fixedBytesDraw(buf -> lib.hegel_generate_ipv6(ctx(), pointer(tc), buf), out16); + } + + @FunctionalInterface + private interface BytesDraw { + int run(Pointer out); + } + + /** Run a draw writing into a fixed-size byte buffer, copying it out on success. */ + private static int fixedBytesDraw(BytesDraw draw, byte[] out) { + Memory buf = new Memory(out.length); + int code = draw.run(buf); + if (code == Abi.OK) { + buf.read(0, out, 0, out.length); + } + return code; + } + + // --- string-generator handles --- + + @Override + public int stringGeneratorText( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters, + long[] out) { + Pointer categoriesArr = cstrArray(categories); + Pointer excludeArr = cstrArray(excludeCategories); + byte[] include = utf8OrNull(includeCharacters); + byte[] exclude = utf8OrNull(excludeCharacters); + PointerByReference outRef = new PointerByReference(); + int code = lib.hegel_string_generator_text( + ctx(), + minSize, + maxSize, + codec, + (int) minCodepoint, + (int) maxCodepoint, + categoriesArr, + categories == null ? 0L : categories.size(), + excludeArr, + excludeCategories == null ? 0L : excludeCategories.size(), + bytesOrNull(include), + include == null ? 0L : include.length, + bytesOrNull(exclude), + exclude == null ? 0L : exclude.length, + outRef); + // Read the (null-initialised) out slot unconditionally: callers check the return code + // before using it. + out[0] = address(outRef.getValue()); + return code; + } + + private static byte[] utf8OrNull(String s) { + return s == null ? null : s.getBytes(StandardCharsets.UTF_8); + } + + private static Pointer bytesOrNull(byte[] bytes) { + if (bytes == null) { + return null; + } + Memory buf = new Memory(Math.max(bytes.length, 1)); + buf.write(0, bytes, 0, bytes.length); + return buf; + } + + /** A NULL-distinct {@code char**}: {@code null} maps to NULL, an empty list to a valid pointer. */ + private static Pointer cstrArray(List strings) { + return strings == null ? null : new StringArray(strings.toArray(new String[0]), "UTF-8"); + } + + @Override + public int stringGeneratorRegex(String pattern, boolean fullmatch, long alphabet, long[] out) { + PointerByReference outRef = new PointerByReference(); + int code = lib.hegel_string_generator_regex(ctx(), pattern, cbool(fullmatch), pointer(alphabet), outRef); + out[0] = address(outRef.getValue()); + return code; + } + + @Override + public int stringGeneratorEmail(long[] out) { + PointerByReference outRef = new PointerByReference(); + int code = lib.hegel_string_generator_email(ctx(), outRef); + out[0] = address(outRef.getValue()); + return code; + } + + @Override + public int stringGeneratorUrl(long[] out) { + PointerByReference outRef = new PointerByReference(); + int code = lib.hegel_string_generator_url(ctx(), outRef); + out[0] = address(outRef.getValue()); + return code; + } + + @Override + public int stringGeneratorDomain(long maxLength, long[] out) { + PointerByReference outRef = new PointerByReference(); + int code = lib.hegel_string_generator_domain(ctx(), maxLength, outRef); + out[0] = address(outRef.getValue()); + return code; + } + + @Override + public void stringGeneratorFree(long generator) { + check("hegel_string_generator_free", lib.hegel_string_generator_free(ctx(), pointer(generator))); + } + + // --- structure --- + + @Override + public int startSpan(long tc, long label) { + return lib.hegel_start_span(ctx(), pointer(tc), label); + } + + @Override + public int stopSpan(long tc, boolean discard) { + return lib.hegel_stop_span(ctx(), pointer(tc), cbool(discard)); + } + + @Override + public int newCollection(long tc, long minSize, long maxSize, long[] outId) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_new_collection(ctx(), pointer(tc), minSize, maxSize, ref); + outId[0] = ref.getValue(); + return code; + } + + @Override + public int collectionMore(long tc, long id, boolean[] outMore) { + ByteByReference ref = new ByteByReference(); + int code = lib.hegel_collection_more(ctx(), pointer(tc), id, ref); + if (code == Abi.OK) { + outMore[0] = ref.getValue() != 0; + } + return code; + } + + @Override + public int collectionReject(long tc, long id, String why) { + return lib.hegel_collection_reject(ctx(), pointer(tc), id, why); + } + + @Override + public int newPool(long tc, long[] outId) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_new_pool(ctx(), pointer(tc), ref); + outId[0] = ref.getValue(); + return code; + } + + @Override + public int poolAdd(long tc, long poolId, long[] outVariableId) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_pool_add(ctx(), pointer(tc), poolId, ref); + outVariableId[0] = ref.getValue(); + return code; + } + + @Override + public int poolGenerate(long tc, long poolId, boolean consume, long[] outVariableId) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_pool_generate(ctx(), pointer(tc), poolId, cbool(consume), ref); + outVariableId[0] = ref.getValue(); + return code; + } + + @Override + public int newStateMachine( + long tc, + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + long stepCount, + long[] outId, + long[] outConcurrency) { + Memory groups = new Memory(8L * Math.max(ruleGroups.length, 1)); + groups.write(0, ruleGroups, 0, ruleGroups.length); + // NULL keeps every rule at the same weight. + Pointer weights = Pointer.NULL; + if (ruleWeights != null) { + Memory m = new Memory(8L * Math.max(ruleWeights.length, 1)); + m.write(0, ruleWeights, 0, ruleWeights.length); + weights = m; + } + byte[] flags = new byte[invariantAlwaysCheck.length]; + for (int i = 0; i < flags.length; i++) { + flags[i] = cbool(invariantAlwaysCheck[i]); + } + Memory alwaysCheck = new Memory(Math.max(flags.length, 1)); + alwaysCheck.write(0, flags, 0, flags.length); + LongByReference id = new LongByReference(); + LongByReference concurrency = new LongByReference(); + int code = lib.hegel_new_state_machine( + ctx(), + pointer(tc), + cstrArray(ruleNames), + groups, + weights, + ruleNames.size(), + cstrArray(invariantNames), + alwaysCheck, + invariantNames.size(), + minConcurrency, + maxConcurrency, + stepCount, + id, + concurrency); + if (code == Abi.OK) { + outId[0] = id.getValue(); + outConcurrency[0] = concurrency.getValue(); + } + return code; + } + + @Override + public int stateMachineNextGroup(long tc, long stateMachineId, long[] outGroupId) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_state_machine_next_group(ctx(), pointer(tc), stateMachineId, ref); + if (code == Abi.OK) { + outGroupId[0] = ref.getValue(); + } + return code; + } + + @Override + public int stateMachineNextRule(long tc, long stateMachineId, long workerIndex, long[] outRuleIndex) { + LongByReference ref = new LongByReference(); + int code = lib.hegel_state_machine_next_rule(ctx(), pointer(tc), stateMachineId, workerIndex, ref); + if (code == Abi.OK) { + outRuleIndex[0] = ref.getValue(); + } + return code; + } + + @Override + public int stateMachineRuleRejected(long tc, long stateMachineId, long workerIndex) { + return lib.hegel_state_machine_rule_rejected(ctx(), pointer(tc), stateMachineId, workerIndex); + } + + @Override + public int stateMachineShouldCheckInvariant( + long tc, long stateMachineId, long invariantIndex, boolean[] outShouldCheck) { + ByteByReference ref = new ByteByReference(); + int code = + lib.hegel_state_machine_should_check_invariant(ctx(), pointer(tc), stateMachineId, invariantIndex, ref); + if (code == Abi.OK) { + outShouldCheck[0] = ref.getValue() != 0; + } + return code; + } + + @Override + public void stateMachineFree(long stateMachineId) { + check("hegel_state_machine_free", lib.hegel_state_machine_free(ctx(), stateMachineId)); + } + + @Override + public int target(long tc, double value, String label) { + return lib.hegel_target(ctx(), pointer(tc), value, label); + } + + @Override + public int markComplete(long tc, int status, String origin) { + return lib.hegel_mark_complete(ctx(), pointer(tc), status, origin); + } + + // --- results --- + + @Override + public int runResultStatus(long result) { + IntByReference ref = new IntByReference(); + check("hegel_run_result_status", lib.hegel_run_result_status(ctx(), pointer(result), ref)); + return ref.getValue(); + } + + @Override + public String runResultError(long result) { + PointerByReference ref = new PointerByReference(); + check("hegel_run_result_error", lib.hegel_run_result_error(ctx(), pointer(result), ref)); + return readCString(ref.getValue()); + } + + @Override + public long runResultFailureCount(long result) { + LongByReference ref = new LongByReference(); + check("hegel_run_result_failure_count", lib.hegel_run_result_failure_count(ctx(), pointer(result), ref)); + return ref.getValue(); + } + + @Override + public String failureBlob(long result, long index) { + PointerByReference failureOut = new PointerByReference(); + check("hegel_run_result_failure", lib.hegel_run_result_failure(ctx(), pointer(result), index, failureOut)); + Pointer failure = failureOut.getValue(); + PointerByReference blobOut = new PointerByReference(); + check("hegel_failure_reproduction_blob", lib.hegel_failure_reproduction_blob(ctx(), failure, blobOut)); + String blob = readCString(blobOut.getValue()); + check("hegel_failure_free", lib.hegel_failure_free(ctx(), failure)); + return blob; + } + + @Override + public String failureOrigin(long result, long index) { + PointerByReference failureOut = new PointerByReference(); + check("hegel_run_result_failure", lib.hegel_run_result_failure(ctx(), pointer(result), index, failureOut)); + Pointer failure = failureOut.getValue(); + PointerByReference originOut = new PointerByReference(); + check("hegel_failure_origin", lib.hegel_failure_origin(ctx(), failure, originOut)); + String origin = readCString(originOut.getValue()); + check("hegel_failure_free", lib.hegel_failure_free(ctx(), failure)); + return origin; + } + + @Override + public String failureCaveat(long result, long index) { + PointerByReference failureOut = new PointerByReference(); + check("hegel_run_result_failure", lib.hegel_run_result_failure(ctx(), pointer(result), index, failureOut)); + Pointer failure = failureOut.getValue(); + PointerByReference caveatOut = new PointerByReference(); + check("hegel_failure_caveat", lib.hegel_failure_caveat(ctx(), failure, caveatOut)); + String caveat = readCString(caveatOut.getValue()); + check("hegel_failure_free", lib.hegel_failure_free(ctx(), failure)); + return caveat; + } + + // --- diagnostics --- + + @Override + public String lastErrorMessage() { + return readCString(lib.hegel_context_last_error(ctx())); + } + + @Override + public String version() { + PointerByReference ref = new PointerByReference(); + check("hegel_version", lib.hegel_version(ctx(), ref)); + return readCString(ref.getValue()); + } +} diff --git a/hegel-jna/src/main/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend b/hegel-jna/src/main/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend new file mode 100644 index 0000000..e07d2cf --- /dev/null +++ b/hegel-jna/src/main/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend @@ -0,0 +1 @@ +dev.hegel.JnaBackend diff --git a/hegel-jna/src/test/java/dev/hegel/JnaLibhegelTest.java b/hegel-jna/src/test/java/dev/hegel/JnaLibhegelTest.java new file mode 100644 index 0000000..b7a81bc --- /dev/null +++ b/hegel-jna/src/test/java/dev/hegel/JnaLibhegelTest.java @@ -0,0 +1,258 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.jna.Memory; +import com.sun.jna.Pointer; +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.LibraryLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** Covers {@link JnaLibhegel} edge branches that the normal engine path does not reach. */ +class JnaLibhegelTest { + + private static JnaLibhegel real() { + return new JnaLibhegel(LibraryLoader.fromEnvironment().resolve()); + } + + @Test + void constructorRejectsBadPath() { + assertThrows(HegelException.class, () -> new JnaLibhegel(Path.of("/nonexistent/libhegel.so"))); + } + + @Test + void callbackDecodesAndSwallowsExceptions() { + byte[] bytes = "hello".getBytes(StandardCharsets.UTF_8); + Memory line = new Memory(bytes.length); + line.write(0, bytes, 0, bytes.length); + AtomicReference got = new AtomicReference<>(); + new JnaLibhegel.LineCallback(got::set).invoke(Pointer.NULL, line, bytes.length); + assertEquals("hello", got.get()); + // A throwing consumer must be swallowed: an exception escaping a native callback must + // never unwind into the engine. + Consumer throwing = s -> { + throw new IllegalStateException("never escapes"); + }; + JnaLibhegel.emitLine(throwing, line, bytes.length); + } + + @Test + void readCStringHandlesNullAndValue() { + assertNull(JnaLibhegel.readCString(null)); + byte[] bytes = "hello\0".getBytes(StandardCharsets.UTF_8); + Memory value = new Memory(bytes.length); + value.write(0, bytes, 0, bytes.length); + assertEquals("hello", JnaLibhegel.readCString(value)); + } + + @Test + void versionReadsAndFreshContextHasNoError() { + JnaLibhegel lib = real(); + assertNotNull(lib.version()); + String message = lib.lastErrorMessage(); + assertTrue(message == null || message.isEmpty(), String.valueOf(message)); + } + + @Test + void infrastructureCallsReportNullHandles() { + JnaLibhegel lib = real(); + // A NULL handle on an infra call surfaces as a HegelException carrying the engine's + // diagnostic rather than undefined behaviour. + assertThrows(HegelException.class, () -> lib.runResultStatus(0)); + assertThrows(HegelException.class, () -> lib.runStart(0, null)); + assertThrows(HegelException.class, () -> lib.runStartBlob(0, "blob", null)); + assertThrows(HegelException.class, () -> lib.testCaseShouldCapture(0)); + assertThrows(HegelException.class, () -> lib.testCaseSetWorker(0, 0)); + long[] clone = {7}; + assertEquals(Abi.E_INVALID_HANDLE, lib.testCaseClone(0, clone)); + assertEquals(7, clone[0]); + } + + @Test + void runWithDefaultOutputStartsAndFrees() { + JnaLibhegel lib = real(); + long s = newSettings(lib); + long run = lib.runStart(s, null); + assertNotEquals(0, run); + lib.runFree(run); + lib.settingsFree(s); + } + + @Test + void scalarDrawsReportNullHandles() { + JnaLibhegel lib = real(); + // Out-slots are seeded with sentinels: a failed call must not copy anything out. + boolean[] b = {true}; + assertEquals(Abi.E_INVALID_HANDLE, lib.generateBoolean(0, 0.5, b)); + assertTrue(b[0]); + long[] i = {7}; + assertEquals(Abi.E_INVALID_HANDLE, lib.generateInteger(0, 0, 10, i)); + assertEquals(7, i[0]); + double[] f = {1.5}; + assertEquals(Abi.E_INVALID_HANDLE, lib.generateFloat(0, 64, 0, 1, false, false, false, false, 0, f)); + assertEquals(1.5, f[0]); + } + + @Test + void structuredDrawsReportNullHandles() { + JnaLibhegel lib = real(); + java.time.LocalDate d = java.time.LocalDate.of(2000, 1, 1); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateDate(0, d, d, new java.time.LocalDate[1])); + java.time.LocalTime t = java.time.LocalTime.NOON; + assertEquals(Abi.E_INVALID_HANDLE, lib.generateTime(0, t, t, new java.time.LocalTime[1])); + java.time.LocalDateTime dt = java.time.LocalDateTime.of(d, t); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateDatetime(0, dt, dt, new java.time.LocalDateTime[1])); + } + + @Test + void fixedBytesDrawsReportNullHandles() { + JnaLibhegel lib = real(); + // A NULL test case is rejected before the engine writes anything, so fixedBytesDraw never + // reaches its copy-out and each buffer keeps the caller's bytes. + byte[] uuid = sentinel(16); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateUuid(0, 0, false, uuid)); + assertArrayEquals(sentinel(16), uuid); + + byte[] ipv4 = sentinel(4); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateIpv4(0, ipv4)); + assertArrayEquals(sentinel(4), ipv4); + + byte[] ipv6 = sentinel(16); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateIpv6(0, ipv6)); + assertArrayEquals(sentinel(16), ipv6); + } + + /** A buffer of non-zero bytes, so an unwanted copy-out is visible rather than a no-op. */ + private static byte[] sentinel(int length) { + byte[] b = new byte[length]; + java.util.Arrays.fill(b, (byte) 0x7f); + return b; + } + + @Test + void stateMachineCallsReportNullHandle() { + JnaLibhegel lib = real(); + // Both handles are NULL: the engine rejects the call on the test case before it + // dereferences the state machine, so this is a clean error rather than undefined behaviour. + // Out-parameters are read only on success, so a failed call leaves the caller's values alone. + long[] out = {7}; + long[] concurrency = {9}; + boolean[] check = {true}; + assertEquals( + Abi.E_INVALID_HANDLE, + lib.newStateMachine( + 0, + List.of("r"), + new long[] {0}, + null, + List.of("i"), + new boolean[] {true}, + 1, + 1, + 50, + out, + concurrency)); + assertEquals(7, out[0]); + assertEquals(9, concurrency[0]); + // Explicit weights are marshalled too (NULL above keeps the engine's all-equal default). + assertEquals( + Abi.E_INVALID_HANDLE, + lib.newStateMachine( + 0, + List.of("r"), + new long[] {0}, + new double[] {2.5}, + List.of("i"), + new boolean[] {true}, + 1, + 1, + 50, + out, + concurrency)); + assertEquals(7, out[0]); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineNextGroup(0, 0, out)); + assertEquals(7, out[0]); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineNextRule(0, 0, 0, out)); + assertEquals(7, out[0]); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineRuleRejected(0, 0, 0)); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineShouldCheckInvariant(0, 0, 0, check)); + assertTrue(check[0]); + // Freeing NULL is a documented no-op. + lib.stateMachineFree(0); + } + + @Test + void settingsGettersReadTheHandleBack() { + JnaLibhegel lib = real(); + long s = newSettings(lib); + lib.settingsTestCases(s, 7); + assertEquals(7, lib.settingsGetTestCases(s)); + lib.settingsPrintBlob(s, false); + assertFalse(lib.settingsGetPrintBlob(s)); + lib.settingsPrintBlob(s, true); + assertTrue(lib.settingsGetPrintBlob(s)); + lib.settingsNondeterminismStrictness(s, Abi.NONDETERMINISM_ERROR); + assertEquals(Abi.NONDETERMINISM_ERROR, lib.settingsGetNondeterminismStrictness(s)); + lib.settingsNondeterminismStrictness(s, Abi.NONDETERMINISM_WARN); + assertEquals(Abi.NONDETERMINISM_WARN, lib.settingsGetNondeterminismStrictness(s)); + lib.settingsFree(s); + } + + private static long newSettings(JnaLibhegel lib) { + long[] out = new long[1]; + assertEquals(Abi.OK, lib.settingsNew(out)); + return out[0]; + } + + @Test + void undecodableBlobWithDefaultOutputIsRejected() { + JnaLibhegel lib = real(); + long s = newSettings(lib); + long[] out = new long[1]; + // A null output callback leaves replay output on stderr; the garbage blob is rejected. + assertEquals(Abi.E_INVALID_ARG, lib.testCaseFromBlob(s, "not-a-blob!!!", null, out)); + lib.settingsFree(s); + } + + @Test + void blobRunWithDefaultOutputStartsAndReportsTheBadBlob() { + JnaLibhegel lib = real(); + long s = newSettings(lib); + // A null output callback leaves the run's output on stderr; the garbage blob surfaces as + // the run's error once it is pumped dry. + long run = lib.runStartBlob(s, "not-a-blob!!!", null); + assertNotEquals(0, run); + assertEquals(0, lib.nextTestCase(run)); + long result = lib.runResult(run); + assertEquals(Abi.RUN_STATUS_ERROR, lib.runResultStatus(result)); + assertNotNull(lib.runResultError(result)); + lib.runResultFree(result); + lib.runFree(run); + lib.settingsFree(s); + } + + @Test + void regexGeneratorAcceptsATextAlphabet() { + JnaLibhegel lib = real(); + long[] alphabet = new long[1]; + assertEquals( + Abi.OK, + lib.stringGeneratorText(0, 5, "ascii", 0, Abi.NO_MAX_CODEPOINT, null, null, null, null, alphabet)); + long[] regex = new long[1]; + assertEquals(Abi.OK, lib.stringGeneratorRegex("[a-z]+", true, alphabet[0], regex)); + lib.stringGeneratorFree(regex[0]); + lib.stringGeneratorFree(alphabet[0]); + } +} diff --git a/hegel-lowlevel/pom.xml b/hegel-lowlevel/pom.xml new file mode 100644 index 0000000..d3a1c3a --- /dev/null +++ b/hegel-lowlevel/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + dev.hegel + hegel-parent + 0.10.0 + + + hegel-lowlevel + jar + + hegel-java (low-level binding contract) + The libhegel binding contract for people binding Hegel or building frontends on it: the Libhegel interface, the C ABI constants, and library resolution (Java 17+) + + + 17 + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + dev.hegel.lowlevel + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-shared-sources + + + ${project.build.directory}/generated-sources/java-templates + + + + + add-shared-test-sources + none + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + ${project.basedir}/src/test/resources/dummy-libhegel + + + + + + com.diffplug.spotless + spotless-maven-plugin + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + org.jacoco + jacoco-maven-plugin + + + + diff --git a/src/main/java-templates/dev/hegel/BuildInfo.java b/hegel-lowlevel/src/main/java-templates/dev/hegel/lowlevel/BuildInfo.java similarity index 94% rename from src/main/java-templates/dev/hegel/BuildInfo.java rename to hegel-lowlevel/src/main/java-templates/dev/hegel/lowlevel/BuildInfo.java index 8857ded..c20851f 100644 --- a/src/main/java-templates/dev/hegel/BuildInfo.java +++ b/hegel-lowlevel/src/main/java-templates/dev/hegel/lowlevel/BuildInfo.java @@ -1,4 +1,4 @@ -package dev.hegel; +package dev.hegel.lowlevel; /** * Build-time constants filtered in by Maven from {@code src/main/java-templates}. Generated; do not diff --git a/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Abi.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Abi.java new file mode 100644 index 0000000..9a376e3 --- /dev/null +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Abi.java @@ -0,0 +1,72 @@ +package dev.hegel.lowlevel; + +/** + * Constants from the libhegel C ABI ({@code hegel-c/include/hegel.h}): return codes, run and case + * statuses, phase and health-check bit masks, and the sentinels the structured primitives use. + * Kept in sync with the engine header. + */ +public final class Abi { + private Abi() {} + + // Return codes (hegel_result_t). + public static final int OK = 0; + public static final int E_STOP_TEST = -1; + public static final int E_ASSUME = -2; + public static final int E_BACKEND = -3; + public static final int E_INVALID_HANDLE = -4; + public static final int E_INVALID_ARG = -5; + public static final int E_ALREADY_COMPLETE = -6; + public static final int E_NOT_COMPLETE = -7; + public static final int E_INTERNAL = -8; + public static final int E_CONCURRENT_USE = -9; + public static final int E_RETRY = -10; + + // Aggregate run outcome (hegel_run_status_t). Value 3 (FAILED_NONDETERMINISTIC, retired in the + // 0.44 ABI) is never reported and must not be reused: a failing nondeterministic run reports + // plain FAILED, with a caveat on each failure. + public static final int RUN_STATUS_PASSED = 0; + public static final int RUN_STATUS_FAILED = 1; + public static final int RUN_STATUS_ERROR = 2; + + // hegel_nondeterminism_strictness_t: how a run reacts on detecting a nondeterministic test. + public static final int NONDETERMINISM_QUIET = 0; + public static final int NONDETERMINISM_WARN = 1; + public static final int NONDETERMINISM_ERROR = 2; + + // Phases (bitmask for hegel_settings_set_phases). + public static final int PHASE_EXPLICIT = 1 << 0; + public static final int PHASE_REUSE = 1 << 1; + public static final int PHASE_GENERATE = 1 << 2; + public static final int PHASE_TARGET = 1 << 3; + public static final int PHASE_SHRINK = 1 << 4; + public static final int PHASE_ALL = 31; + + // Health-check suppression bitmask (hegel_settings_set_suppress_health_check). + public static final int HC_FILTER_TOO_MUCH = 1 << 0; + public static final int HC_TOO_SLOW = 1 << 1; + public static final int HC_TEST_CASES_TOO_LARGE = 1 << 2; + public static final int HC_LARGE_INITIAL_TEST_CASE = 1 << 3; + + // hegel_backend_t. There is no automatic value: leaving the backend unset lets the engine's + // settings profile choose (the shipped `workload` profile, selected inside Antithesis, uses + // urandom). + public static final int BACKEND_DEFAULT = 1; + public static final int BACKEND_URANDOM = 2; + + // hegel_verbosity_t. + public static final int VERBOSITY_NORMAL = 0; + public static final int VERBOSITY_QUIET = 1; + public static final int VERBOSITY_VERBOSE = 2; + public static final int VERBOSITY_DEBUG = 3; + + // hegel_status_t (argument to hegel_mark_complete). + public static final int STATUS_VALID = 0; + public static final int STATUS_INVALID = 1; + public static final int STATUS_OVERRUN = 2; + public static final int STATUS_INTERESTING = 3; + + // Sentinel written by hegel_state_machine_next_group / hegel_state_machine_next_rule (INT64_MIN). + public static final long STATE_MACHINE_DONE = Long.MIN_VALUE; + public static final long UNBOUNDED = -1L; // 0xFFFFFFFFFFFFFFFF as a Java long + public static final long NO_MAX_CODEPOINT = 0xFFFFFFFFL; +} diff --git a/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Generated.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Generated.java new file mode 100644 index 0000000..7e5aacc --- /dev/null +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Generated.java @@ -0,0 +1,17 @@ +package dev.hegel.lowlevel; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method as excluded from coverage measurement. + * + *

JaCoCo automatically ignores members annotated with an annotation whose name contains + * "Generated". Used only for genuinely unreachable defensive catch blocks (here: a {@code + * NoSuchAlgorithmException} for SHA-256, which the JLS mandates to exist). + */ +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.METHOD, ElementType.TYPE}) +@interface Generated {} diff --git a/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Libhegel.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Libhegel.java new file mode 100644 index 0000000..113528d --- /dev/null +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/Libhegel.java @@ -0,0 +1,372 @@ +package dev.hegel.lowlevel; + +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Iterator; +import java.util.List; +import java.util.ServiceLoader; +import java.util.function.Consumer; + +/** + * The libhegel binding surface, as a table of operations: one method per {@code hegel_*} function + * in {@code hegel.h}. + * + *

This is the contract between a binding (an implementation over some FFI mechanism) + * and a frontend (code that drives the engine). Hegel ships two bindings — the Foreign + * Function and Memory API binding in {@code dev.hegel:hegel} and the JNA binding in {@code + * dev.hegel:hegel-jna} — each registered as a {@link LibhegelBackend}; {@link #load()} picks up + * whichever is on the classpath. Frontends may also substitute a fake for tests. + * + *

Opaque handles ({@code hegel_settings_t*}, {@code hegel_run_t*}, {@code hegel_test_case_t*}, + * {@code hegel_run_result_t*}, {@code hegel_string_generator_t*}) are passed as raw addresses + * ({@code long}; {@code 0} is NULL); callers treat them as opaque and never dereference them. + * Handles are caller-owned: every handle a method returns must be released with its matching + * {@code *Free} method. A binding keeps one {@code hegel_context_t} per thread internally, so the + * C API's leading context argument does not appear here; its last error message is read with + * {@link #lastErrorMessage()}. + * + *

Two calling conventions coexist here, mirroring how a frontend consumes the ABI: + * + *

    + *
  • Per-test-case primitives (draws, spans, collections, pools, state machines, {@code target}, + * {@code markComplete}) and the string-generator constructors return the raw libhegel return + * code ({@link Abi#OK}, {@link Abi#E_STOP_TEST}, {@link Abi#E_ASSUME}, ...); the frontend + * translates it and reads {@link #lastErrorMessage()} immediately on a non-OK code. + * Out-values are written into caller-supplied one-element arrays only on {@link Abi#OK} + * (except where noted). + *
  • Infrastructure calls (settings setters and getters, run lifecycle, result readers, frees) + * cannot legitimately fail with well-formed arguments, so implementations check the return + * code themselves and throw {@link LibhegelException} on an unexpected non-OK code. {@link + * #settingsNew} is the exception: the engine resolves the settings profile and the {@code + * HEGEL_*} environment variables while constructing the handle, so it follows the first + * convention and returns the raw code. + *
+ * + *

Strings the engine returns are copied out before the method returns, so they remain valid. + * Implementations are thread-safe; a process needs one instance. + */ +public interface Libhegel { + /** + * Resolve {@code libhegel} (see {@link LibraryLoader}) and open it with the binding registered + * on the classpath. + * + * @return a binding over the resolved library + * @throws LibhegelException if no library can be resolved or no binding is registered + */ + static Libhegel load() { + return load(LibraryLoader.fromEnvironment().resolve()); + } + + /** + * Open the shared library at {@code library} with the binding registered on the classpath + * (through {@link ServiceLoader}). With several registered, the first one found wins. + * + * @param library the path of the {@code libhegel} shared object + * @return a binding over that library + * @throws LibhegelException if no binding is registered + */ + static Libhegel load(Path library) { + return load(library, ServiceLoader.load(LibhegelBackend.class)); + } + + /** + * Open the shared library at {@code library} with the first of {@code backends}. + * + * @param library the path of the {@code libhegel} shared object + * @param backends candidate bindings, in preference order + * @return a binding over that library + * @throws LibhegelException if {@code backends} is empty + */ + static Libhegel load(Path library, Iterable backends) { + Iterator it = backends.iterator(); + if (!it.hasNext()) { + throw new LibhegelException("No libhegel binding is registered on the classpath. Add dev.hegel:hegel" + + " (FFM, Java 22+) or dev.hegel:hegel-jna (JNA, Java 17+), or register your own" + + " dev.hegel.lowlevel.LibhegelBackend service provider."); + } + return it.next().open(library); + } + + // Settings. Setters and getters cannot fail with this binding's inputs; implementations throw + // on non-OK. + + /** + * {@code hegel_settings_new}: a handle initialized from the engine's default settings profile + * (a {@code hegel.toml} in the working directory or an ancestor, {@code HEGEL_DEFAULT_PROFILE}, + * and the shipped {@code development}/{@code ci}/{@code workload} profiles) with the {@code + * HEGEL_TEST_CASES}, {@code HEGEL_DATABASE}, {@code HEGEL_STATISTICS}, {@code HEGEL_SEED}, + * {@code HEGEL_DERANDOMIZE}, {@code HEGEL_PRINT_BLOB} and {@code HEGEL_NONDETERMINISM_STRICTNESS} + * environment variables applied over it. + * Returns {@link Abi#E_INVALID_ARG} (with the message in {@link #lastErrorMessage()}) when a + * {@code hegel.toml} or one of those variables is malformed; {@code out[0]} receives the handle + * on {@link Abi#OK} and {@code 0} otherwise. + */ + int settingsNew(long[] out); + + void settingsFree(long s); + + /** {@code hegel_settings_get_test_cases}: the resolved test-case budget. */ + long settingsGetTestCases(long s); + + /** {@code hegel_settings_get_print_blob}: whether the resolved settings print reproduce blobs. */ + boolean settingsGetPrintBlob(long s); + + /** + * {@code hegel_settings_get_nondeterminism_strictness}: the resolved reaction to a + * nondeterministic test ({@link Abi#NONDETERMINISM_QUIET}, {@link Abi#NONDETERMINISM_WARN} or + * {@link Abi#NONDETERMINISM_ERROR}). + */ + int settingsGetNondeterminismStrictness(long s); + + void settingsBackend(long s, int backend); + + void settingsTestCases(long s, long n); + + void settingsVerbosity(long s, int v); + + void settingsSeed(long s, long seed, boolean hasSeed); + + void settingsDerandomize(long s, boolean derandomize); + + void settingsReportMultipleFailures(long s, boolean yes); + + /** + * {@code path == null} leaves the engine default; {@code ""} disables; otherwise sets the dir. + */ + void settingsDatabase(long s, String path); + + void settingsDatabaseKey(long s, String key); + + void settingsPhases(long s, int mask); + + void settingsSuppressHealthCheck(long s, int mask); + + /** {@code hegel_settings_set_print_blob}: whether to print a reproduce blob per failure. */ + void settingsPrintBlob(long s, boolean yes); + + /** + * {@code hegel_settings_set_nondeterminism_strictness}: how the run reacts when it detects a + * test whose structure or outcome changes when the same choices are replayed — one of {@link + * Abi#NONDETERMINISM_QUIET}, {@link Abi#NONDETERMINISM_WARN} or {@link Abi#NONDETERMINISM_ERROR}. + */ + void settingsNondeterminismStrictness(long s, int strictness); + + // Run lifecycle. + + /** + * Start a run. Engine output (progress lines, verbose traces) is delivered per line to {@code + * output}; {@code null} leaves it on stderr. The callback stays registered until {@link + * #runFree}. + */ + long runStart(long settings, Consumer output); + + /** + * {@code hegel_run_start_blob}: start a run that replays a reproduce blob until a replay fails + * (under the engine's bounded budget) instead of exploring. Driven exactly like a run from + * {@link #runStart}: a reproducing replay is the run's failure, a run with no failures means + * the blob is stale, and an undecodable blob surfaces as the run's error from {@link + * #runResultError}. {@code output} has the same contract as in {@link #runStart}. + */ + long runStartBlob(long settings, String blob, Consumer output); + + /** The next test-case handle, or {@code 0} once the run is finished. */ + long nextTestCase(long run); + + /** A caller-owned snapshot of the finished run's result; release with {@link #runResultFree}. */ + long runResult(long run); + + void runResultFree(long result); + + void runFree(long run); + + /** + * Replay a base64 reproduce blob as a standalone test case, in a single attempt. Returns the raw + * rc ({@link Abi#E_INVALID_ARG} for a corrupt or incompatible blob); on OK, {@code out[0]} + * receives the caller-owned handle. {@code output} has the same contract as in {@link #runStart} + * but need not outlive the call. A nondeterministic blob may need several attempts to fail, so + * reproduce-failure features should prefer {@link #runStartBlob}. + */ + int testCaseFromBlob(long settings, String blob, Consumer output, long[] out); + + void testCaseFree(long tc); + + /** + * {@code hegel_test_case_should_capture}: whether the engine stamped this case for capture — the + * caller should keep the case's output and, if it fails, its exception, keyed by the failure's + * origin, because a stamped failing execution is the material for that origin's failure report. + * Read once at case start. + */ + boolean testCaseShouldCapture(long tc); + + /** + * {@code hegel_test_case_clone}: a caller-owned handle onto an independent choice stream of + * the same test case. The clone shares the case's outcome and budgets (and its collections, + * pools and state machines, which are family-wide) but draws from its own sequence, so a clone + * and its source can be driven concurrently from different threads while staying + * deterministic under replay. Release it with {@link #testCaseFree}. + * + *

Cloning consumes one choice position on the source stream, so it is a draw: it returns + * the raw rc — {@link Abi#E_STOP_TEST} when the choice budget is exhausted (as on a replay of + * a shorter sequence), {@link Abi#E_CONCURRENT_USE} if the source is mid-operation on another + * thread, {@link Abi#E_ALREADY_COMPLETE} once the case has completed — and on OK writes the + * handle into {@code out[0]}. + */ + int testCaseClone(long tc, long[] out); + + /** + * {@code hegel_test_case_set_worker}: attribute the handle's engine-side output (notes and + * printer lines) to concurrent worker {@code workerIndex}, which must be non-negative. Blocks + * and clones derived from the handle afterwards inherit the attribution. + */ + void testCaseSetWorker(long tc, long workerIndex); + + // Per-test-case draws. Each returns the raw rc. + int generateBoolean(long tc, double p, boolean[] out); + + int generateInteger(long tc, long min, long max, long[] out); + + int generateFloat( + long tc, + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude, + double[] out); + + int generateBytes(long tc, long minSize, long maxSize, byte[][] out); + + int generateString(long tc, long generator, String[] out); + + int generateDate(long tc, LocalDate min, LocalDate max, LocalDate[] out); + + /** Time bounds and results are at microsecond resolution (the engine's granularity). */ + int generateTime(long tc, LocalTime min, LocalTime max, LocalTime[] out); + + int generateDatetime(long tc, LocalDateTime min, LocalDateTime max, LocalDateTime[] out); + + /** On OK writes the UUID's 16 big-endian bytes into {@code out16}. */ + int generateUuid(long tc, int version, boolean hasVersion, byte[] out16); + + /** On OK writes the address's 4 network-order bytes into {@code out4}. */ + int generateIpv4(long tc, byte[] out4); + + /** On OK writes the address's 16 network-order bytes into {@code out16}. */ + int generateIpv6(long tc, byte[] out16); + + // String-generator handles. Constructors return the raw rc (INVALID_ARG for a configuration + // that is rejected, e.g. an empty alphabet with max_size > 0); handles are released with + // stringGeneratorFree. + int stringGeneratorText( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters, + long[] out); + + int stringGeneratorRegex(String pattern, boolean fullmatch, long alphabet, long[] out); + + int stringGeneratorEmail(long[] out); + + int stringGeneratorUrl(long[] out); + + int stringGeneratorDomain(long maxLength, long[] out); + + void stringGeneratorFree(long generator); + + // Structure: spans, collections, pools, state machines. Each returns the raw rc. + int startSpan(long tc, long label); + + int stopSpan(long tc, boolean discard); + + int newCollection(long tc, long minSize, long maxSize, long[] outId); + + int collectionMore(long tc, long id, boolean[] outMore); + + int collectionReject(long tc, long id, String why); + + int newPool(long tc, long[] outId); + + int poolAdd(long tc, long poolId, long[] outVariableId); + + int poolGenerate(long tc, long poolId, boolean consume, long[] outVariableId); + + /** + * {@code hegel_new_state_machine}: {@code ruleGroups} is parallel to {@code ruleNames} (any + * value but {@link Abi#STATE_MACHINE_DONE}), as is {@code ruleWeights} — each rule's selection + * weight relative to the other enabled rules of its group, finite and strictly positive, or + * {@code null} for all-equal weights. {@code invariantAlwaysCheck} is parallel to {@code + * invariantNames}. The engine draws the concurrency level in {@code [minConcurrency, + * maxConcurrency]} and writes it to {@code outConcurrency}; pass {@code 1, 1} for a sequential + * machine. {@code stepCount} is the target number of counted rounds per test case (at least 1; + * the engine has no default, and 50 is the conventional choice). + */ + int newStateMachine( + long tc, + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + long stepCount, + long[] outId, + long[] outConcurrency); + + /** The id of the group current for the new round, or {@link Abi#STATE_MACHINE_DONE}. */ + int stateMachineNextGroup(long tc, long stateMachineId, long[] outGroupId); + + /** {@code outRuleIndex[0]} receives the rule index, or {@link Abi#STATE_MACHINE_DONE}. */ + int stateMachineNextRule(long tc, long stateMachineId, long workerIndex, long[] outRuleIndex); + + int stateMachineRuleRejected(long tc, long stateMachineId, long workerIndex); + + int stateMachineShouldCheckInvariant(long tc, long stateMachineId, long invariantIndex, boolean[] outShouldCheck); + + /** {@code hegel_state_machine_free}: the handle is independent of its test case and run. */ + void stateMachineFree(long stateMachineId); + + int target(long tc, double value, String label); + + int markComplete(long tc, int status, String origin); + + // Results. + int runResultStatus(long result); + + /** The run-level error message, or {@code null} when the run completed normally. */ + String runResultError(long result); + + long runResultFailureCount(long result); + + /** + * The reproduce blob of the {@code index}-th distinct failure, or {@code null} if libhegel + * produced none for it. + */ + String failureBlob(long result, long index); + + /** The origin string the shrinker grouped the {@code index}-th distinct failure under. */ + String failureOrigin(long result, long index); + + /** + * {@code hegel_failure_caveat}: the {@code index}-th distinct failure's confirmation caveat — its + * standing under the run's nondeterministic handling, quoting the run's replay evidence — or + * {@code null} for a deterministic failure. + */ + String failureCaveat(long result, long index); + + // Diagnostics. + String lastErrorMessage(); + + String version(); +} diff --git a/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibhegelBackend.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibhegelBackend.java new file mode 100644 index 0000000..6f4ef0b --- /dev/null +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibhegelBackend.java @@ -0,0 +1,24 @@ +package dev.hegel.lowlevel; + +import java.nio.file.Path; + +/** + * Service provider interface for a {@code libhegel} binding. + * + *

A binding jar implements this interface with a public class that has a public no-argument + * constructor and registers it in {@code META-INF/services/dev.hegel.lowlevel.LibhegelBackend}. + * {@link Libhegel#load()} finds it through {@link java.util.ServiceLoader}. The two bindings Hegel + * ships — the Foreign Function and Memory API binding in {@code dev.hegel:hegel} and the JNA + * binding in {@code dev.hegel:hegel-jna} — are registered this way; a third-party binding (JNI, + * a GraalVM native, ...) plugs in the same way. + */ +public interface LibhegelBackend { + /** + * Open the shared library at {@code library} and return a binding over it. + * + * @param library the resolved path of the {@code libhegel} shared object + * @return a thread-safe binding; callers keep one per process + * @throws LibhegelException if the library cannot be opened or lacks a required symbol + */ + Libhegel open(Path library); +} diff --git a/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibhegelException.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibhegelException.java new file mode 100644 index 0000000..e57c45a --- /dev/null +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibhegelException.java @@ -0,0 +1,26 @@ +package dev.hegel.lowlevel; + +/** + * Thrown by a binding for anything that is not a test outcome: the library could not be resolved + * or opened, an infrastructure call returned an unexpected code, an engine invariant was violated. + * + *

Per-test-case primitives report their expected non-OK codes (stop-test, assumption rejected, + * invalid argument) as return values, not as this exception; see {@link Libhegel}. The Java + * frontend's {@code dev.hegel.HegelException} extends this class. + */ +public class LibhegelException extends RuntimeException { + /** + * @param message the diagnostic + */ + public LibhegelException(String message) { + super(message); + } + + /** + * @param message the diagnostic + * @param cause the underlying failure + */ + public LibhegelException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/dev/hegel/LibraryLoader.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibraryLoader.java similarity index 54% rename from src/main/java/dev/hegel/LibraryLoader.java rename to hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibraryLoader.java index 7ca86ee..92f4288 100644 --- a/src/main/java/dev/hegel/LibraryLoader.java +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/LibraryLoader.java @@ -1,4 +1,4 @@ -package dev.hegel; +package dev.hegel.lowlevel; import java.io.IOException; import java.io.InputStream; @@ -21,40 +21,67 @@ *

  • {@code $HEGEL_LIBHEGEL_PATH} — explicit override (e.g. for local engine development); if * set it must point at an existing file, otherwise resolution fails. *
  • the OS's standard shared-library search path ({@code LD_LIBRARY_PATH} on Linux, {@code - * DYLD_LIBRARY_PATH} on macOS): the first directory containing the library file is used. - *
  • the native library bundled in the jar for this OS/arch, unpacked to a per-user cache. + * DYLD_LIBRARY_PATH} on macOS, {@code PATH} on Windows): the first directory containing the + * library file is used. + *
  • the native library bundled in the jar for this OS/arch, unpacked to a per-user cache + * ({@code $XDG_CACHE_HOME}/{@code ~/.cache} on Linux and macOS, {@code %LOCALAPPDATA%} on + * Windows). * * *

    The bundled libraries are placed on the classpath at build time (see {@code * scripts/fetch_natives.py}), so the shipped jar is self-contained and nothing is downloaded at - * runtime. Configuration (environment, cache dir, OS/arch, and the resource opener) is injected so - * the resolver is fully unit-testable, including the unpack path. + * runtime. The per-user cache is purely a performance optimization: when it cannot be read or + * written (e.g. a sandbox that denies access to the user cache dir), the library is extracted to a + * fresh directory under the system temp dir instead. Configuration (environment, cache dir, + * OS/arch, the resource opener, and the temp-dir supplier) is injected so the resolver is fully + * unit-testable, including the unpack path. */ -final class LibraryLoader { +public final class LibraryLoader { + /** Creates a fresh private directory for the temp-dir fallback; injected for testability. */ + @FunctionalInterface + interface TempDirSupplier { + Path create() throws IOException; + } + private final Map env; private final Path cacheDir; private final String os; private final String arch; private final Function resources; + private final TempDirSupplier tempDirs; LibraryLoader( Map env, Path cacheDir, String os, String arch, Function resources) { + this(env, cacheDir, os, arch, resources, () -> Files.createTempDirectory("hegel-java-libhegel")); + } + + LibraryLoader( + Map env, + Path cacheDir, + String os, + String arch, + Function resources, + TempDirSupplier tempDirs) { this.env = env; this.cacheDir = cacheDir; this.os = os; this.arch = arch; this.resources = resources; + this.tempDirs = tempDirs; } /** * Build a loader from the real process environment, reading bundled natives off the classpath. + * + * @return a loader for this process */ - static LibraryLoader fromEnvironment() { + public static LibraryLoader fromEnvironment() { Map env = System.getenv(); + String os = mapOs(System.getProperty("os.name")); return new LibraryLoader( env, - defaultCacheDir(env), - mapOs(System.getProperty("os.name")), + defaultCacheDir(env, os), + os, mapArch(System.getProperty("os.arch")), LibraryLoader::classpathResource); } @@ -64,9 +91,26 @@ static InputStream classpathResource(String name) { return LibraryLoader.class.getClassLoader().getResourceAsStream(name); } - static Path defaultCacheDir(Map env) { + /** + * The per-user cache directory for unpacked natives: {@code $XDG_CACHE_HOME} if set (an + * explicit override on every OS), else the idiomatic per-OS cache root — {@code + * %LOCALAPPDATA%} on Windows, {@code ~/.cache} elsewhere. + */ + static Path defaultCacheDir(Map env, String os) { String xdg = env.get("XDG_CACHE_HOME"); - Path base = (xdg != null && !xdg.isEmpty()) ? Path.of(xdg) : Path.of(home(env), ".cache"); + if (xdg != null && !xdg.isEmpty()) { + return cacheSubdir(Path.of(xdg)); + } + if (os.equals("windows")) { + String localAppData = env.get("LOCALAPPDATA"); + if (localAppData != null && !localAppData.isEmpty()) { + return cacheSubdir(Path.of(localAppData)); + } + } + return cacheSubdir(Path.of(home(env), ".cache")); + } + + private static Path cacheSubdir(Path base) { return base.resolve("hegel-java").resolve("libhegel"); } @@ -83,8 +127,11 @@ static String mapOs(String osName) { if (os.contains("linux")) { return "linux"; } - throw new HegelException( - "libhegel does not support this operating system: '" + osName + "' (linux/macOS only)."); + if (os.contains("windows")) { + return "windows"; + } + throw new LibhegelException( + "libhegel does not support this operating system: '" + osName + "' (Linux, macOS, and Windows only)."); } static String mapArch(String osArch) { @@ -93,23 +140,31 @@ static String mapArch(String osArch) { case "amd64", "x86_64" -> "amd64"; case "aarch64", "arm64" -> "arm64"; default -> - throw new HegelException( + throw new LibhegelException( "libhegel does not support this architecture: '" + osArch + "' (amd64/arm64 only)."); }; } private String libExt() { - return os.equals("darwin") ? "dylib" : "so"; + return switch (os) { + case "windows" -> "dll"; + case "darwin" -> "dylib"; + default -> "so"; + }; } - /** The shared-library file name for this OS (e.g. {@code libhegel.so}). */ + /** The shared-library file name for this OS (e.g. {@code libhegel.so}, {@code libhegel.dll}). */ private String libFileName() { return "libhegel." + libExt(); } /** The OS's conventional shared-library search-path environment variable. */ private String libraryPathVar() { - return os.equals("darwin") ? "DYLD_LIBRARY_PATH" : "LD_LIBRARY_PATH"; + return switch (os) { + case "windows" -> "PATH"; + case "darwin" -> "DYLD_LIBRARY_PATH"; + default -> "LD_LIBRARY_PATH"; + }; } /** Classpath resource path of the native library bundled for this OS/arch. */ @@ -118,14 +173,14 @@ String resourcePath() { } /** Resolve a usable libhegel path, unpacking the bundled native if necessary. */ - Path resolve() { + public Path resolve() { String override = env.get("HEGEL_LIBHEGEL_PATH"); if (override != null && !override.isEmpty()) { Path p = Path.of(override); if (Files.isRegularFile(p)) { return p; } - throw new HegelException("HEGEL_LIBHEGEL_PATH is set to '" + override + "' but no file exists there."); + throw new LibhegelException("HEGEL_LIBHEGEL_PATH is set to '" + override + "' but no file exists there."); } Path onPath = searchLibraryPath(); @@ -138,7 +193,7 @@ Path resolve() { return bundled; } - throw new HegelException("Could not find libhegel: no library bundled for " + throw new LibhegelException("Could not find libhegel: no library bundled for " + os + "-" + arch @@ -170,9 +225,10 @@ Path searchLibraryPath() { } /** - * Unpack the bundled native for this OS/arch to the cache and return its path, or {@code null} if - * no native is bundled for this platform. The cache entry is keyed by the library's content hash, - * so it is reused across runs and never collides between engine versions. + * Unpack the bundled native for this OS/arch and return its path, or {@code null} if no native + * is bundled for this platform. The per-user cache is tried first; on any cache failure the + * library is extracted to a fresh directory under the system temp dir instead. Unpacking fails + * only when both paths fail, with an error reporting both causes. */ Path unpackBundled() { InputStream in = resources.apply(resourcePath()); @@ -183,25 +239,55 @@ Path unpackBundled() { try { bytes = readAndClose(in); } catch (IOException e) { - throw new HegelException("Failed to read bundled libhegel resource " + resourcePath(), e); + throw new LibhegelException("Failed to read bundled libhegel resource " + resourcePath(), e); + } + IOException cacheFailure; + try { + return cachedLibrary(bytes); + } catch (IOException e) { + cacheFailure = e; } + try { + return tempLibrary(bytes); + } catch (IOException e) { + e.addSuppressed(cacheFailure); + throw new LibhegelException( + "Failed to unpack bundled libhegel to a temp dir (cache also unusable: " + cacheFailure + ")", e); + } + } + + /** + * The per-user cached copy of the library, written on first use. The cache entry is keyed by + * the library's content hash, so it is reused across runs and never collides between engine + * versions. + */ + private Path cachedLibrary(byte[] bytes) throws IOException { Path dir = cacheDir.resolve(sha256Hex(bytes)); Path target = dir.resolve(libFileName()); if (Files.isRegularFile(target) && target.toFile().length() == bytes.length) { return target; } + Files.createDirectories(dir); + return installLibrary(dir, bytes); + } + + /** + * Extract the library to a fresh private directory under the system temp dir. + */ + private Path tempLibrary(byte[] bytes) throws IOException { + return installLibrary(tempDirs.create(), bytes); + } + + /** Write the library into {@code dir} atomically (temp file + rename), marked executable. */ + private Path installLibrary(Path dir, byte[] bytes) throws IOException { + Path target = dir.resolve(libFileName()); + Path tmp = Files.createTempFile(dir, "libhegel", ".part"); try { - Files.createDirectories(dir); - Path tmp = Files.createTempFile(dir, "libhegel", ".part"); - try { - Files.write(tmp, bytes); - tmp.toFile().setExecutable(true, false); - Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } finally { - Files.deleteIfExists(tmp); - } - } catch (IOException e) { - throw new HegelException("Failed to unpack bundled libhegel to " + target, e); + Files.write(tmp, bytes); + tmp.toFile().setExecutable(true, false); + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(tmp); } return target; } @@ -212,17 +298,24 @@ private static byte[] readAndClose(InputStream in) throws IOException { } } - /** The engine version these bindings were built against. */ - static String targetEngineVersion() { + /** + * The engine version these bindings were built and tested against. + * + * @return the pinned libhegel version, e.g. {@code "0.37.6"} + */ + public static String targetEngineVersion() { return BuildInfo.ENGINE_VERSION; } /** * Warn on {@code err} if a loaded engine reports a different version than the one these bindings * target. Silent when the versions match or either is unknown. + * + * @param loaded the loaded engine's {@link Libhegel#version()} + * @param expected the version to compare against, normally {@link #targetEngineVersion()} + * @param err where to print the warning */ - static void warnOnVersionMismatch(Libhegel lib, String expected, PrintStream err) { - String loaded = lib.version(); + public static void warnOnVersionMismatch(String loaded, String expected, PrintStream err) { if (expected == null || loaded == null || loaded.equals(expected)) { return; } @@ -243,7 +336,7 @@ private static MessageDigest sha256Digest() { try { return MessageDigest.getInstance("SHA-256"); } catch (java.security.NoSuchAlgorithmException e) { - throw new HegelException("SHA-256 unavailable", e); + throw new LibhegelException("SHA-256 unavailable", e); } } } diff --git a/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/package-info.java b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/package-info.java new file mode 100644 index 0000000..99b728a --- /dev/null +++ b/hegel-lowlevel/src/main/java/dev/hegel/lowlevel/package-info.java @@ -0,0 +1,29 @@ +/** + * The {@code libhegel} binding contract: what a binding implements and what a frontend consumes. + * + *

    This package is for two audiences, neither of which is an ordinary Hegel user (they want + * {@code dev.hegel.Hegel} and {@code dev.hegel.Generators} from {@code dev.hegel:hegel} or {@code + * dev.hegel:hegel-jna}): + * + *

      + *
    • People binding Hegel — implementing {@link dev.hegel.lowlevel.Libhegel} + * over some FFI mechanism (JNI, a GraalVM native, ...) and registering it as a {@link + * dev.hegel.lowlevel.LibhegelBackend}. The interface mirrors {@code hegel.h} one method per + * function, so the C header's documentation is the binding's specification. + *
    • People building a frontend from scratch — driving the engine's run loop + * and per-case primitives themselves rather than through the Java {@code TestCase} and + * generator layer. {@link dev.hegel.lowlevel.Libhegel#load()} finds whichever binding is on + * the classpath; {@link dev.hegel.lowlevel.Abi} holds the constants. + *
    + * + *

    The contract deliberately stays close to the C ABI: opaque handles are raw {@code long} + * addresses ({@code 0} is NULL) that the caller owns and must free, fallible per-case primitives + * return the raw {@code hegel_result_t} code with results in out-parameters, and engine strings are + * copied out before a method returns. Everything higher — exceptions for return codes, generators, + * reporting — is frontend policy and lives in the frontend jars. + * + *

    Stability. This package is experimental. Consumers of {@code Libhegel} see only + * additive changes as the engine grows, but implementors must expect new abstract methods with each + * engine release that adds functions. + */ +package dev.hegel.lowlevel; diff --git a/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/LibhegelLoadTest.java b/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/LibhegelLoadTest.java new file mode 100644 index 0000000..9a14fed --- /dev/null +++ b/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/LibhegelLoadTest.java @@ -0,0 +1,60 @@ +package dev.hegel.lowlevel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Binding discovery through the {@link LibhegelBackend} service provider interface. */ +class LibhegelLoadTest { + @Test + void loadFindsTheRegisteredBackend() { + // The test classpath registers StubBackend under META-INF/services. + Path lib = Path.of("/some/libhegel.so"); + Libhegel got = Libhegel.load(lib); + assertNotNull(got); + assertEquals(lib, StubBackend.lastOpened); + assertEquals(lib.toString(), got.version()); + } + + @Test + void zeroArgumentLoadResolvesTheLibraryFirst() { + // Surefire points HEGEL_LIBHEGEL_PATH at a dummy file (this module bundles no native), so + // resolution succeeds and the registered stub backend is asked to open that path. + Libhegel got = Libhegel.load(); + assertNotNull(got); + assertTrue(StubBackend.lastOpened.endsWith("dummy-libhegel"), StubBackend.lastOpened.toString()); + } + + @Test + void explicitBackendsAreTriedInOrder() { + Path lib = Path.of("/x/libhegel.so"); + Libhegel first = StubBackend.stub("first"); + Libhegel second = StubBackend.stub("second"); + LibhegelBackend a = library -> first; + LibhegelBackend b = library -> second; + assertSame(first, Libhegel.load(lib, List.of(a, b))); + assertSame(second, Libhegel.load(lib, List.of(b, a))); + } + + @Test + void noBackendIsAClearError() { + LibhegelException e = + assertThrows(LibhegelException.class, () -> Libhegel.load(Path.of("/x/libhegel.so"), List.of())); + assertTrue(e.getMessage().contains("dev.hegel:hegel-jna"), e.getMessage()); + } + + @Test + void exceptionCarriesMessageAndCause() { + Throwable cause = new IllegalStateException("root"); + LibhegelException e = new LibhegelException("wrapped", cause); + assertEquals("wrapped", e.getMessage()); + assertSame(cause, e.getCause()); + assertEquals("plain", new LibhegelException("plain").getMessage()); + } +} diff --git a/src/test/java/dev/hegel/LibraryLoaderTest.java b/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/LibraryLoaderTest.java similarity index 67% rename from src/test/java/dev/hegel/LibraryLoaderTest.java rename to hegel-lowlevel/src/test/java/dev/hegel/lowlevel/LibraryLoaderTest.java index 3fdf829..5ee704e 100644 --- a/src/test/java/dev/hegel/LibraryLoaderTest.java +++ b/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/LibraryLoaderTest.java @@ -1,7 +1,8 @@ -package dev.hegel; +package dev.hegel.lowlevel; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -41,35 +42,60 @@ void mapOsAndArch() { assertEquals("linux", LibraryLoader.mapOs("Linux")); assertEquals("darwin", LibraryLoader.mapOs("Mac OS X")); assertEquals("darwin", LibraryLoader.mapOs("Darwin")); - assertThrows(HegelException.class, () -> LibraryLoader.mapOs("Windows 11")); + assertEquals("windows", LibraryLoader.mapOs("Windows 11")); + assertThrows(LibhegelException.class, () -> LibraryLoader.mapOs("FreeBSD")); assertEquals("amd64", LibraryLoader.mapArch("amd64")); assertEquals("amd64", LibraryLoader.mapArch("x86_64")); assertEquals("arm64", LibraryLoader.mapArch("aarch64")); assertEquals("arm64", LibraryLoader.mapArch("arm64")); - assertThrows(HegelException.class, () -> LibraryLoader.mapArch("ppc64")); + assertThrows(LibhegelException.class, () -> LibraryLoader.mapArch("ppc64")); } @Test void defaultCacheDirHonoursXdgThenHome() { assertEquals( - Path.of("/xdg/hegel-java/libhegel"), LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "/xdg"))); - assertEquals(Path.of("/h/.cache/hegel-java/libhegel"), LibraryLoader.defaultCacheDir(Map.of("HOME", "/h"))); + Path.of("/xdg/hegel-java/libhegel"), + LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "/xdg"), "linux")); + assertEquals( + Path.of("/h/.cache/hegel-java/libhegel"), LibraryLoader.defaultCacheDir(Map.of("HOME", "/h"), "linux")); } @Test void cacheDirAndHomeEdgeCases() { assertEquals( Path.of("/h/.cache/hegel-java/libhegel"), - LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "", "HOME", "/h"))); + LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "", "HOME", "/h"), "linux")); // No HOME and no XDG falls back to the user.home system property. - Path d = LibraryLoader.defaultCacheDir(Map.of()); + Path d = LibraryLoader.defaultCacheDir(Map.of(), "linux"); assertTrue(d.endsWith(Path.of("hegel-java/libhegel"))); // Empty HOME also falls back to user.home. - Path d2 = LibraryLoader.defaultCacheDir(Map.of("HOME", "")); + Path d2 = LibraryLoader.defaultCacheDir(Map.of("HOME", ""), "linux"); assertTrue(d2.endsWith(Path.of("hegel-java/libhegel"))); } + @Test + void windowsCacheDirPrefersLocalAppData() { + assertEquals( + Path.of("/lad/hegel-java/libhegel"), + LibraryLoader.defaultCacheDir(Map.of("LOCALAPPDATA", "/lad", "HOME", "/h"), "windows")); + // XDG_CACHE_HOME is an explicit override on every OS, Windows included. + assertEquals( + Path.of("/xdg/hegel-java/libhegel"), + LibraryLoader.defaultCacheDir(Map.of("XDG_CACHE_HOME", "/xdg", "LOCALAPPDATA", "/lad"), "windows")); + // Unset or empty LOCALAPPDATA falls back to the POSIX-style default. + assertEquals( + Path.of("/h/.cache/hegel-java/libhegel"), + LibraryLoader.defaultCacheDir(Map.of("HOME", "/h"), "windows")); + assertEquals( + Path.of("/h/.cache/hegel-java/libhegel"), + LibraryLoader.defaultCacheDir(Map.of("LOCALAPPDATA", "", "HOME", "/h"), "windows")); + // LOCALAPPDATA is ignored off-Windows. + assertEquals( + Path.of("/h/.cache/hegel-java/libhegel"), + LibraryLoader.defaultCacheDir(Map.of("LOCALAPPDATA", "/lad", "HOME", "/h"), "linux")); + } + @Test void sha256OfEmptyInput() { assertEquals( @@ -83,6 +109,8 @@ void resourcePathPerPlatform(@TempDir Path dir) { assertEquals("native/linux-amd64/libhegel.so", linux.resourcePath()); LibraryLoader darwin = new LibraryLoader(Map.of(), dir, "darwin", "arm64", NO_RESOURCES); assertEquals("native/darwin-arm64/libhegel.dylib", darwin.resourcePath()); + LibraryLoader windows = new LibraryLoader(Map.of(), dir, "windows", "amd64", NO_RESOURCES); + assertEquals("native/windows-amd64/libhegel.dll", windows.resourcePath()); } @Test @@ -101,7 +129,7 @@ void overrideUsesExactPath(@TempDir Path dir) throws IOException { @Test void overrideMissingFails(@TempDir Path dir) { LibraryLoader l = loader(Map.of("HEGEL_LIBHEGEL_PATH", "/no/such.so"), dir, NO_RESOURCES); - assertThrows(HegelException.class, l::resolve); + assertThrows(LibhegelException.class, l::resolve); } @Test @@ -166,6 +194,16 @@ void darwinSearchesDyldLibraryPath(@TempDir Path dir) throws IOException { assertEquals(lib, l.resolve()); } + @Test + void windowsSearchesPath(@TempDir Path dir) throws IOException { + Path libDir = Files.createDirectories(dir.resolve("libs")); + Path lib = libDir.resolve("libhegel.dll"); + Files.writeString(lib, "win-lib"); + Map env = Map.of("PATH", libDir.toString()); + LibraryLoader l = new LibraryLoader(new HashMap<>(env), dir.resolve("cache"), "windows", "amd64", NO_RESOURCES); + assertEquals(lib, l.resolve()); + } + @Test void bundledNativeUnpackedAndCached(@TempDir Path dir) throws IOException { byte[] payload = "ELF-ish-bytes".getBytes(StandardCharsets.UTF_8); @@ -193,7 +231,7 @@ void staleCacheEntryIsReplaced(@TempDir Path dir) throws IOException { @Test void noBundledNativeFails(@TempDir Path dir) { LibraryLoader l = loader(Map.of(), dir.resolve("cache"), NO_RESOURCES); - HegelException e = assertThrows(HegelException.class, l::resolve); + LibhegelException e = assertThrows(LibhegelException.class, l::resolve); assertTrue(e.getMessage().contains(LINUX_RESOURCE)); } @@ -206,7 +244,7 @@ public int read() throws IOException { } }; LibraryLoader l = loader(Map.of(), dir.resolve("cache"), failing); - HegelException e = assertThrows(HegelException.class, l::resolve); + LibhegelException e = assertThrows(LibhegelException.class, l::resolve); assertTrue(e.getMessage().contains("Failed to read bundled libhegel")); } @@ -220,32 +258,43 @@ void targetEngineVersionIsBundled() { @Test void warnOnVersionMismatchOnlyWarnsOnRealMismatch() { - FakeLibhegel fake = new FakeLibhegel(); - - fake.version = "9.9.9"; - assertTrue(warnOutput(fake, "0.14.14").contains("9.9.9")); - - fake.version = "0.14.14"; - assertEquals("", warnOutput(fake, "0.14.14")); // matching: silent - assertEquals("", warnOutput(fake, null)); // expected unknown: silent - - fake.version = null; - assertEquals("", warnOutput(fake, "0.14.14")); // loaded unknown: silent + assertTrue(warnOutput("9.9.9", "0.30.4").contains("9.9.9")); + assertEquals("", warnOutput("0.30.4", "0.30.4")); // matching: silent + assertEquals("", warnOutput("0.30.4", null)); // expected unknown: silent + assertEquals("", warnOutput(null, "0.30.4")); // loaded unknown: silent } - private static String warnOutput(FakeLibhegel lib, String expected) { + private static String warnOutput(String loaded, String expected) { ByteArrayOutputStream buf = new ByteArrayOutputStream(); - LibraryLoader.warnOnVersionMismatch(lib, expected, new PrintStream(buf, true)); + LibraryLoader.warnOnVersionMismatch(loaded, expected, new PrintStream(buf, true)); return buf.toString(); } @Test - void unpackIoErrorFails(@TempDir Path dir) throws IOException { - byte[] payload = "x".getBytes(StandardCharsets.UTF_8); + void unusableCacheFallsBackToTempDir(@TempDir Path dir) throws IOException { + byte[] payload = "ELF-ish-bytes".getBytes(StandardCharsets.UTF_8); Path cacheAsFile = dir.resolve("cache-is-a-file"); Files.writeString(cacheAsFile, "occupied"); // createDirectories under it must fail LibraryLoader l = loader(Map.of(), cacheAsFile, bundled(LINUX_RESOURCE, payload)); - HegelException e = assertThrows(HegelException.class, l::resolve); - assertTrue(e.getMessage().contains("Failed to unpack bundled libhegel")); + Path got = l.resolve(); // the default supplier extracts under the system temp dir + assertFalse(got.startsWith(cacheAsFile)); + assertArrayEquals(payload, Files.readAllBytes(got)); + } + + @Test + void cacheAndTempDirBothUnusableFails(@TempDir Path dir) throws IOException { + byte[] payload = "x".getBytes(StandardCharsets.UTF_8); + Path cacheAsFile = dir.resolve("cache-is-a-file"); + Files.writeString(cacheAsFile, "occupied"); + LibraryLoader l = + new LibraryLoader(Map.of(), cacheAsFile, "linux", "amd64", bundled(LINUX_RESOURCE, payload), () -> { + throw new IOException("temp denied"); + }); + LibhegelException e = assertThrows(LibhegelException.class, l::resolve); + // The terminal error reports both causes: the temp failure as the cause, the cache failure + // in the message (and suppressed on the cause, so both stack traces survive). + assertTrue(e.getCause().getMessage().contains("temp denied")); + assertTrue(e.getMessage().contains("cache also unusable")); + assertTrue(e.getMessage().contains("cache-is-a-file")); } } diff --git a/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/StubBackend.java b/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/StubBackend.java new file mode 100644 index 0000000..82cf2db --- /dev/null +++ b/hegel-lowlevel/src/test/java/dev/hegel/lowlevel/StubBackend.java @@ -0,0 +1,28 @@ +package dev.hegel.lowlevel; + +import java.lang.reflect.Proxy; +import java.nio.file.Path; + +/** + * The test-classpath {@link LibhegelBackend} service provider (registered under {@code + * META-INF/services}): opens nothing and hands back a {@link Libhegel} proxy that remembers the path + * it was "opened" with. + */ +public final class StubBackend implements LibhegelBackend { + /** The path most recently passed to {@link #open}. */ + static Path lastOpened; + + @Override + public Libhegel open(Path library) { + lastOpened = library; + return stub(library.toString()); + } + + /** A {@link Libhegel} whose {@code version()} is {@code version} and whose other methods return null. */ + static Libhegel stub(String version) { + return (Libhegel) Proxy.newProxyInstance( + Libhegel.class.getClassLoader(), + new Class[] {Libhegel.class}, + (proxy, method, args) -> method.getName().equals("version") ? version : null); + } +} diff --git a/hegel-lowlevel/src/test/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend b/hegel-lowlevel/src/test/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend new file mode 100644 index 0000000..56ffd2c --- /dev/null +++ b/hegel-lowlevel/src/test/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend @@ -0,0 +1 @@ +dev.hegel.lowlevel.StubBackend diff --git a/hegel-lowlevel/src/test/resources/dummy-libhegel b/hegel-lowlevel/src/test/resources/dummy-libhegel new file mode 100644 index 0000000..26ce47a --- /dev/null +++ b/hegel-lowlevel/src/test/resources/dummy-libhegel @@ -0,0 +1 @@ +not a real library; HEGEL_LIBHEGEL_PATH points here so Libhegel.load() resolves without a bundled native diff --git a/hegel/pom.xml b/hegel/pom.xml new file mode 100644 index 0000000..582c317 --- /dev/null +++ b/hegel/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + + dev.hegel + hegel-parent + 0.10.0 + + + hegel + jar + + hegel-java + Property-based testing for Java, built on Hypothesis (FFM binding, Java 22+) + + + + dev.hegel + hegel-lowlevel + ${project.version} + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + dev.hegel + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + org.codehaus.mojo + exec-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.plugins + maven-invoker-plugin + + + module-consumer-it + + install + run + + + + + + + com.diffplug.spotless + spotless-maven-plugin + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + org.jacoco + jacoco-maven-plugin + + + + diff --git a/hegel/src/it/module-consumer/invoker.properties b/hegel/src/it/module-consumer/invoker.properties new file mode 100644 index 0000000..bb145c5 --- /dev/null +++ b/hegel/src/it/module-consumer/invoker.properties @@ -0,0 +1,3 @@ +# `test` compiles the module (resolving `requires dev.hegel;`, the module-name guard) and runs +# the @HegelTest against the real engine, consuming hegel on the module path end to end. +invoker.goals = test diff --git a/hegel/src/it/module-consumer/pom.xml b/hegel/src/it/module-consumer/pom.xml new file mode 100644 index 0000000..788429a --- /dev/null +++ b/hegel/src/it/module-consumer/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + dev.hegel.it + module-consumer + 1.0 + jar + + + UTF-8 + 22 + + + + + dev.hegel + hegel + + @project.version@ + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + --enable-native-access=dev.hegel + + + + + diff --git a/hegel/src/it/module-consumer/src/main/java/module-info.java b/hegel/src/it/module-consumer/src/main/java/module-info.java new file mode 100644 index 0000000..43617f6 --- /dev/null +++ b/hegel/src/it/module-consumer/src/main/java/module-info.java @@ -0,0 +1,6 @@ +// The presence of `module-info.java` puts hegel on the module path. `requires dev.hegel;` +// names the automatic module by its committed `Automatic-Module-Name`; if that name ever +// changes, this no longer resolves and the build fails — which is the regression we guard. +module dev.hegel.consumer { + requires dev.hegel; +} diff --git a/hegel/src/it/module-consumer/src/test/java/dev/hegel/consumer/ConsumerTest.java b/hegel/src/it/module-consumer/src/test/java/dev/hegel/consumer/ConsumerTest.java new file mode 100644 index 0000000..a4f4922 --- /dev/null +++ b/hegel/src/it/module-consumer/src/test/java/dev/hegel/consumer/ConsumerTest.java @@ -0,0 +1,16 @@ +package dev.hegel.consumer; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.hegel.HegelTest; +import dev.hegel.TestCase; + +class ConsumerTest { + @HegelTest + void additionCommutes(TestCase tc) { + int x = tc.draw(integers().min(0).max(100)); + int y = tc.draw(integers().min(0).max(100)); + assertEquals(x + y, y + x); + } +} diff --git a/hegel/src/main/java/dev/hegel/FfmBackend.java b/hegel/src/main/java/dev/hegel/FfmBackend.java new file mode 100644 index 0000000..e90745e --- /dev/null +++ b/hegel/src/main/java/dev/hegel/FfmBackend.java @@ -0,0 +1,21 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Libhegel; +import dev.hegel.lowlevel.LibhegelBackend; +import java.nio.file.Path; + +/** + * The Foreign Function and Memory API binding, registered as a {@link LibhegelBackend} service + * provider so {@link Libhegel#load()} finds it on the classpath. + * + * @hidden + */ +public final class FfmBackend implements LibhegelBackend { + /** Public no-argument constructor, as {@link java.util.ServiceLoader} requires. */ + public FfmBackend() {} + + @Override + public Libhegel open(Path library) { + return new RealLibhegel(library); + } +} diff --git a/hegel/src/main/java/dev/hegel/RealLibhegel.java b/hegel/src/main/java/dev/hegel/RealLibhegel.java new file mode 100644 index 0000000..ad99803 --- /dev/null +++ b/hegel/src/main/java/dev/hegel/RealLibhegel.java @@ -0,0 +1,1301 @@ +package dev.hegel; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_BOOLEAN; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_DOUBLE; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.Libhegel; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.StructLayout; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * The real libhegel binding, driving the C ABI over the Foreign Function and Memory API. + * + *

    Resolves every C symbol once and caches the resulting {@link MethodHandle}s, which are + * immutable after construction and safe to share across threads. Every call routes through {@link + * #invoke} so error translation and the one-place FFI try/catch live in a single method. + * + *

    Every fallible call takes a {@code hegel_context_t*} as its first argument, on which libhegel + * records the diagnostic of a failed call. A context must not be shared between threads, so each + * thread lazily creates its own; {@link #lastErrorMessage()} reads the current thread's context, + * which is what the failing call just wrote. Contexts are never freed — one small allocation per + * thread that touches the engine, for the life of the process. + */ +final class RealLibhegel implements Libhegel { + private final Arena libArena; + private final Linker linker; + + // hegel_run_start registers the output-callback upcall stub with the engine until + // hegel_run_free, so each run gets a confined arena owning its stub (and out-slot), closed in + // runFree. Runs are started and freed on the same thread (the Runner's), matching confinement. + private final Map runArenas = new ConcurrentHashMap<>(); + + private final ThreadLocal context; + + // struct hegel_date_t { int32_t year; uint8_t month; uint8_t day; } + private static final StructLayout DATE_LAYOUT = MemoryLayout.structLayout( + JAVA_INT.withName("year"), + JAVA_BYTE.withName("month"), + JAVA_BYTE.withName("day"), + MemoryLayout.paddingLayout(2)); + + // struct hegel_time_t { uint8_t hour; uint8_t minute; uint8_t second; uint32_t nanosecond; } + private static final StructLayout TIME_LAYOUT = MemoryLayout.structLayout( + JAVA_BYTE.withName("hour"), + JAVA_BYTE.withName("minute"), + JAVA_BYTE.withName("second"), + MemoryLayout.paddingLayout(1), + JAVA_INT.withName("nanosecond")); + + // struct hegel_datetime_t { hegel_date_t date; hegel_time_t time; } + private static final StructLayout DATETIME_LAYOUT = + MemoryLayout.structLayout(DATE_LAYOUT.withName("date"), TIME_LAYOUT.withName("time")); + + // struct hegel_generate_bytes_result_t / hegel_generate_string_result_t { T* data; size_t len; } + private static final StructLayout BUFFER_RESULT_LAYOUT = + MemoryLayout.structLayout(ADDRESS.withName("data"), JAVA_LONG.withName("len")); + + private static final FunctionDescriptor OUTPUT_CALLBACK_DESC = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_LONG); + + private static final MethodHandle EMIT_LINE = findEmitLine(); + + @Generated // MethodHandles cannot fail to find a private static method of this class. + private static MethodHandle findEmitLine() { + try { + return MethodHandles.lookup() + .findStatic( + RealLibhegel.class, + "emitLine", + MethodType.methodType( + void.class, Consumer.class, MemorySegment.class, MemorySegment.class, long.class)); + } catch (ReflectiveOperationException e) { + throw new HegelException("failed to resolve the output-callback bridge", e); + } + } + + private final MethodHandle contextNew; + private final MethodHandle contextLastError; + private final MethodHandle settingsNew; + private final MethodHandle settingsFree; + private final MethodHandle settingsSetBackend; + private final MethodHandle settingsSetTestCases; + private final MethodHandle settingsSetVerbosity; + private final MethodHandle settingsSetSeed; + private final MethodHandle settingsSetDerandomize; + private final MethodHandle settingsSetReportMultipleFailures; + private final MethodHandle settingsSetDatabase; + private final MethodHandle settingsSetDatabaseKey; + private final MethodHandle settingsSetPhases; + private final MethodHandle settingsSetSuppressHealthCheck; + private final MethodHandle settingsSetPrintBlob; + private final MethodHandle settingsGetTestCases; + private final MethodHandle settingsGetPrintBlob; + private final MethodHandle settingsSetNondeterminismStrictness; + private final MethodHandle settingsGetNondeterminismStrictness; + private final MethodHandle runStart; + private final MethodHandle runStartBlob; + private final MethodHandle nextTestCase; + private final MethodHandle runResult; + private final MethodHandle runResultFree; + private final MethodHandle runFree; + private final MethodHandle testCaseFromBlob; + private final MethodHandle testCaseFree; + private final MethodHandle testCaseShouldCapture; + private final MethodHandle testCaseClone; + private final MethodHandle testCaseSetWorker; + private final MethodHandle generateBoolean; + private final MethodHandle generateInteger; + private final MethodHandle generateFloat; + private final MethodHandle generateBytes; + private final MethodHandle generateBytesResultFree; + private final MethodHandle generateString; + private final MethodHandle generateStringResultFree; + private final MethodHandle generateDate; + private final MethodHandle generateTime; + private final MethodHandle generateDatetime; + private final MethodHandle generateUuid; + private final MethodHandle generateIpv4; + private final MethodHandle generateIpv6; + private final MethodHandle stringGeneratorText; + private final MethodHandle stringGeneratorRegex; + private final MethodHandle stringGeneratorEmail; + private final MethodHandle stringGeneratorUrl; + private final MethodHandle stringGeneratorDomain; + private final MethodHandle stringGeneratorFree; + private final MethodHandle startSpan; + private final MethodHandle stopSpan; + private final MethodHandle newCollection; + private final MethodHandle collectionMore; + private final MethodHandle collectionReject; + private final MethodHandle newPool; + private final MethodHandle poolAdd; + private final MethodHandle poolGenerate; + private final MethodHandle newStateMachine; + private final MethodHandle stateMachineNextGroup; + private final MethodHandle stateMachineNextRule; + private final MethodHandle stateMachineRuleRejected; + private final MethodHandle stateMachineShouldCheckInvariant; + private final MethodHandle stateMachineFree; + private final MethodHandle target; + private final MethodHandle markComplete; + private final MethodHandle runResultStatus; + private final MethodHandle runResultError; + private final MethodHandle runResultFailureCount; + private final MethodHandle runResultFailure; + private final MethodHandle failureFree; + private final MethodHandle failureReproductionBlob; + private final MethodHandle failureOrigin; + private final MethodHandle failureCaveat; + private final MethodHandle version; + + RealLibhegel(Path libraryPath) { + this.libArena = Arena.ofShared(); + this.linker = Linker.nativeLinker(); + SymbolLookup lookup; + try { + lookup = SymbolLookup.libraryLookup(libraryPath, libArena); + } catch (IllegalArgumentException e) { + libArena.close(); + throw new HegelException("Failed to open libhegel at " + libraryPath + ": " + e.getMessage()); + } + + // rc(ctx, args...) descriptors; the leading JAVA_INT is the hegel_result_t return. + this.contextNew = h(linker, lookup, "hegel_context_new", FunctionDescriptor.of(ADDRESS)); + this.contextLastError = h(linker, lookup, "hegel_context_last_error", FunctionDescriptor.of(ADDRESS, ADDRESS)); + this.settingsNew = h(linker, lookup, "hegel_settings_new", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.settingsFree = h(linker, lookup, "hegel_settings_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.settingsSetBackend = h( + linker, + lookup, + "hegel_settings_set_backend", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)); + this.settingsSetTestCases = h( + linker, + lookup, + "hegel_settings_set_test_cases", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG)); + this.settingsSetVerbosity = h( + linker, + lookup, + "hegel_settings_set_verbosity", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)); + this.settingsSetSeed = h( + linker, + lookup, + "hegel_settings_set_seed", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_BOOLEAN)); + this.settingsSetDerandomize = h( + linker, + lookup, + "hegel_settings_set_derandomize", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_BOOLEAN)); + this.settingsSetReportMultipleFailures = h( + linker, + lookup, + "hegel_settings_set_report_multiple_failures", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_BOOLEAN)); + this.settingsSetDatabase = h( + linker, + lookup, + "hegel_settings_set_database", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.settingsSetDatabaseKey = h( + linker, + lookup, + "hegel_settings_set_database_key", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.settingsSetPhases = h( + linker, + lookup, + "hegel_settings_set_phases", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)); + this.settingsSetSuppressHealthCheck = h( + linker, + lookup, + "hegel_settings_set_suppress_health_check", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)); + this.settingsSetPrintBlob = h( + linker, + lookup, + "hegel_settings_set_print_blob", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_BOOLEAN)); + this.settingsGetTestCases = h( + linker, + lookup, + "hegel_settings_get_test_cases", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.settingsGetPrintBlob = h( + linker, + lookup, + "hegel_settings_get_print_blob", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.settingsSetNondeterminismStrictness = h( + linker, + lookup, + "hegel_settings_set_nondeterminism_strictness", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)); + this.settingsGetNondeterminismStrictness = h( + linker, + lookup, + "hegel_settings_get_nondeterminism_strictness", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.runStart = h( + linker, + lookup, + "hegel_run_start", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + this.runStartBlob = h( + linker, + lookup, + "hegel_run_start_blob", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + this.nextTestCase = + h(linker, lookup, "hegel_next_test_case", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.runResult = + h(linker, lookup, "hegel_run_result", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.runResultFree = + h(linker, lookup, "hegel_run_result_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.runFree = h(linker, lookup, "hegel_run_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.testCaseFromBlob = h( + linker, + lookup, + "hegel_test_case_from_blob", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + this.testCaseFree = + h(linker, lookup, "hegel_test_case_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.testCaseShouldCapture = h( + linker, + lookup, + "hegel_test_case_should_capture", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.testCaseClone = + h(linker, lookup, "hegel_test_case_clone", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.testCaseSetWorker = h( + linker, + lookup, + "hegel_test_case_set_worker", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG)); + this.generateBoolean = h( + linker, + lookup, + "hegel_generate_boolean", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_DOUBLE, JAVA_BOOLEAN, JAVA_BOOLEAN, ADDRESS)); + this.generateInteger = h( + linker, + lookup, + "hegel_generate_integer", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)); + this.generateFloat = h( + linker, + lookup, + "hegel_generate_float", + FunctionDescriptor.of( + JAVA_INT, + ADDRESS, + ADDRESS, + JAVA_INT, + JAVA_DOUBLE, + JAVA_DOUBLE, + JAVA_BOOLEAN, + JAVA_BOOLEAN, + JAVA_BOOLEAN, + JAVA_BOOLEAN, + JAVA_DOUBLE, + ADDRESS)); + this.generateBytes = h( + linker, + lookup, + "hegel_generate_bytes", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)); + this.generateBytesResultFree = h( + linker, lookup, "hegel_generate_bytes_result_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.generateString = h( + linker, + lookup, + "hegel_generate_string", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + this.generateStringResultFree = h( + linker, lookup, "hegel_generate_string_result_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.generateDate = h( + linker, + lookup, + "hegel_generate_date", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, DATE_LAYOUT, DATE_LAYOUT, ADDRESS)); + this.generateTime = h( + linker, + lookup, + "hegel_generate_time", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, TIME_LAYOUT, TIME_LAYOUT, ADDRESS)); + this.generateDatetime = h( + linker, + lookup, + "hegel_generate_datetime", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, DATETIME_LAYOUT, DATETIME_LAYOUT, ADDRESS)); + this.generateUuid = h( + linker, + lookup, + "hegel_generate_uuid", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_BYTE, JAVA_BOOLEAN, ADDRESS)); + this.generateIpv4 = + h(linker, lookup, "hegel_generate_ipv4", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.generateIpv6 = + h(linker, lookup, "hegel_generate_ipv6", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.stringGeneratorText = h( + linker, + lookup, + "hegel_string_generator_text", + FunctionDescriptor.of( + JAVA_INT, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS, JAVA_LONG, + ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS)); + this.stringGeneratorRegex = h( + linker, + lookup, + "hegel_string_generator_regex", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_BOOLEAN, ADDRESS, ADDRESS)); + this.stringGeneratorEmail = + h(linker, lookup, "hegel_string_generator_email", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.stringGeneratorUrl = + h(linker, lookup, "hegel_string_generator_url", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.stringGeneratorDomain = h( + linker, + lookup, + "hegel_string_generator_domain", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS)); + this.stringGeneratorFree = + h(linker, lookup, "hegel_string_generator_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.startSpan = + h(linker, lookup, "hegel_start_span", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG)); + this.stopSpan = + h(linker, lookup, "hegel_stop_span", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_BOOLEAN)); + this.newCollection = h( + linker, + lookup, + "hegel_new_collection", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)); + this.collectionMore = h( + linker, + lookup, + "hegel_collection_more", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS)); + this.collectionReject = h( + linker, + lookup, + "hegel_collection_reject", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS)); + this.newPool = h(linker, lookup, "hegel_new_pool", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.poolAdd = h( + linker, + lookup, + "hegel_pool_add", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS)); + this.poolGenerate = h( + linker, + lookup, + "hegel_pool_generate", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_BOOLEAN, ADDRESS)); + // State-machine handles cross as raw addresses (JAVA_LONG), like every other opaque handle. + this.newStateMachine = h( + linker, + lookup, + "hegel_new_state_machine", + FunctionDescriptor.of( + JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG, + JAVA_LONG, JAVA_LONG, JAVA_LONG, ADDRESS, ADDRESS)); + this.stateMachineNextGroup = h( + linker, + lookup, + "hegel_state_machine_next_group", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS)); + this.stateMachineNextRule = h( + linker, + lookup, + "hegel_state_machine_next_rule", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)); + this.stateMachineRuleRejected = h( + linker, + lookup, + "hegel_state_machine_rule_rejected", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG)); + this.stateMachineShouldCheckInvariant = h( + linker, + lookup, + "hegel_state_machine_should_check_invariant", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)); + this.stateMachineFree = + h(linker, lookup, "hegel_state_machine_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG)); + this.target = h( + linker, + lookup, + "hegel_target", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_DOUBLE, ADDRESS)); + this.markComplete = h( + linker, + lookup, + "hegel_mark_complete", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS)); + this.runResultStatus = h( + linker, lookup, "hegel_run_result_status", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.runResultError = + h(linker, lookup, "hegel_run_result_error", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.runResultFailureCount = h( + linker, + lookup, + "hegel_run_result_failure_count", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.runResultFailure = h( + linker, + lookup, + "hegel_run_result_failure", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS)); + this.failureFree = h(linker, lookup, "hegel_failure_free", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.failureReproductionBlob = h( + linker, + lookup, + "hegel_failure_reproduction_blob", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.failureOrigin = + h(linker, lookup, "hegel_failure_origin", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.failureCaveat = + h(linker, lookup, "hegel_failure_caveat", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + this.version = h(linker, lookup, "hegel_version", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + this.context = ThreadLocal.withInitial(() -> (MemorySegment) invoke(contextNew)); + } + + private static MethodHandle h(Linker linker, SymbolLookup lookup, String symbol, FunctionDescriptor desc) { + return linker.downcallHandle(findSymbol(lookup, symbol), desc); + } + + static MemorySegment findSymbol(SymbolLookup lookup, String symbol) { + return lookup.find(symbol) + .orElseThrow(() -> new HegelException("libhegel is missing symbol '" + + symbol + + "' (ABI/version mismatch). Rebuild or update the engine.")); + } + + /** Single point of FFI invocation and error wrapping. */ + static Object invoke(MethodHandle handle, Object... args) { + try { + return handle.invokeWithArguments(args); + } catch (RuntimeException e) { + throw e; + } catch (Throwable t) { + throw new HegelException("libhegel FFI call failed: " + t, t); + } + } + + private MemorySegment ctx() { + return context.get(); + } + + private static MemorySegment segment(long handle) { + return MemorySegment.ofAddress(handle); + } + + private int rc(MethodHandle handle, Object... args) { + Object[] withCtx = new Object[args.length + 1]; + withCtx[0] = ctx(); + System.arraycopy(args, 0, withCtx, 1, args.length); + return (Integer) invoke(handle, withCtx); + } + + private void check(String op, int code) { + if (code != Abi.OK) { + throw new HegelException( + op + " failed (rc=" + code + "): " + java.util.Objects.toString(lastErrorMessage(), "")); + } + } + + private static MemorySegment cstr(Arena a, String s) { + return s == null ? MemorySegment.NULL : a.allocateFrom(s); + } + + static String readCString(MemorySegment ptr) { + if (ptr == null || ptr.address() == 0) { + return null; + } + return ptr.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.UTF_8); + } + + /** + * Bridge one line of engine output to the run's {@link Consumer}. An exception escaping an + * upcall would tear down the VM, so every throwable is swallowed here. + */ + static void emitLine(Consumer output, MemorySegment userData, MemorySegment line, long len) { + try { + byte[] bytes = line.reinterpret(len).toArray(JAVA_BYTE); + output.accept(new String(bytes, StandardCharsets.UTF_8)); + } catch (Throwable t) { + // Deliberately dropped: output delivery must never unwind into the engine. + } + } + + private MemorySegment upcallStub(Arena arena, Consumer output) { + return linker.upcallStub(EMIT_LINE.bindTo(output), OUTPUT_CALLBACK_DESC, arena); + } + + // --- settings --- + + @Override + public int settingsNew(long[] out) { + // The slot is zeroed on allocation and the engine writes it only on success, so a failed + // call reports 0 (NULL) without a branch of its own. + MemorySegment handle = Arena.ofAuto().allocate(ADDRESS); + int code = rc(settingsNew, handle); + out[0] = handle.get(ADDRESS, 0).address(); + return code; + } + + @Override + public void settingsFree(long s) { + check("hegel_settings_free", rc(settingsFree, segment(s))); + } + + @Override + public void settingsBackend(long s, int backend) { + check("hegel_settings_set_backend", rc(settingsSetBackend, segment(s), backend)); + } + + @Override + public void settingsTestCases(long s, long n) { + check("hegel_settings_set_test_cases", rc(settingsSetTestCases, segment(s), n)); + } + + @Override + public void settingsVerbosity(long s, int v) { + check("hegel_settings_set_verbosity", rc(settingsSetVerbosity, segment(s), v)); + } + + @Override + public void settingsSeed(long s, long seed, boolean hasSeed) { + check("hegel_settings_set_seed", rc(settingsSetSeed, segment(s), seed, hasSeed)); + } + + @Override + public void settingsDerandomize(long s, boolean derandomize) { + check("hegel_settings_set_derandomize", rc(settingsSetDerandomize, segment(s), derandomize)); + } + + @Override + public void settingsReportMultipleFailures(long s, boolean yes) { + check("hegel_settings_set_report_multiple_failures", rc(settingsSetReportMultipleFailures, segment(s), yes)); + } + + @Override + public void settingsDatabase(long s, String path) { + // libhegel copies the string during the call, so a per-call auto arena suffices. + check("hegel_settings_set_database", rc(settingsSetDatabase, segment(s), cstr(Arena.ofAuto(), path))); + } + + @Override + public void settingsDatabaseKey(long s, String key) { + check("hegel_settings_set_database_key", rc(settingsSetDatabaseKey, segment(s), cstr(Arena.ofAuto(), key))); + } + + @Override + public void settingsPhases(long s, int mask) { + check("hegel_settings_set_phases", rc(settingsSetPhases, segment(s), mask)); + } + + @Override + public void settingsSuppressHealthCheck(long s, int mask) { + check("hegel_settings_set_suppress_health_check", rc(settingsSetSuppressHealthCheck, segment(s), mask)); + } + + @Override + public void settingsPrintBlob(long s, boolean yes) { + check("hegel_settings_set_print_blob", rc(settingsSetPrintBlob, segment(s), yes)); + } + + @Override + public long settingsGetTestCases(long s) { + MemorySegment out = Arena.ofAuto().allocate(JAVA_LONG); + check("hegel_settings_get_test_cases", rc(settingsGetTestCases, segment(s), out)); + return out.get(JAVA_LONG, 0); + } + + @Override + public boolean settingsGetPrintBlob(long s) { + MemorySegment out = Arena.ofAuto().allocate(JAVA_BOOLEAN); + check("hegel_settings_get_print_blob", rc(settingsGetPrintBlob, segment(s), out)); + return out.get(JAVA_BOOLEAN, 0); + } + + @Override + public void settingsNondeterminismStrictness(long s, int strictness) { + check( + "hegel_settings_set_nondeterminism_strictness", + rc(settingsSetNondeterminismStrictness, segment(s), strictness)); + } + + @Override + public int settingsGetNondeterminismStrictness(long s) { + MemorySegment out = Arena.ofAuto().allocate(JAVA_INT); + check("hegel_settings_get_nondeterminism_strictness", rc(settingsGetNondeterminismStrictness, segment(s), out)); + return out.get(JAVA_INT, 0); + } + + // --- run lifecycle --- + + @Override + public long runStart(long settings, Consumer output) { + Arena arena = Arena.ofConfined(); + MemorySegment callback = output == null ? MemorySegment.NULL : upcallStub(arena, output); + MemorySegment out = arena.allocate(ADDRESS); + int code = rc(runStart, segment(settings), callback, MemorySegment.NULL, out); + if (code != Abi.OK) { + arena.close(); + throw new HegelException( + "hegel_run_start failed (rc=" + code + "): " + java.util.Objects.toString(lastErrorMessage(), "")); + } + long run = out.get(ADDRESS, 0).address(); + runArenas.put(run, arena); + return run; + } + + @Override + public long runStartBlob(long settings, String blob, Consumer output) { + Arena arena = Arena.ofConfined(); + MemorySegment callback = output == null ? MemorySegment.NULL : upcallStub(arena, output); + MemorySegment out = arena.allocate(ADDRESS); + int code = rc(runStartBlob, segment(settings), cstr(arena, blob), callback, MemorySegment.NULL, out); + if (code != Abi.OK) { + arena.close(); + throw new HegelException("hegel_run_start_blob failed (rc=" + + code + + "): " + + java.util.Objects.toString(lastErrorMessage(), "")); + } + long run = out.get(ADDRESS, 0).address(); + runArenas.put(run, arena); + return run; + } + + @Override + public long nextTestCase(long run) { + MemorySegment out = Arena.ofAuto().allocate(ADDRESS); + check("hegel_next_test_case", rc(nextTestCase, segment(run), out)); + return out.get(ADDRESS, 0).address(); + } + + @Override + public long runResult(long run) { + MemorySegment out = Arena.ofAuto().allocate(ADDRESS); + check("hegel_run_result", rc(runResult, segment(run), out)); + return out.get(ADDRESS, 0).address(); + } + + @Override + public void runResultFree(long result) { + check("hegel_run_result_free", rc(runResultFree, segment(result))); + } + + @Override + public void runFree(long run) { + check("hegel_run_free", rc(runFree, segment(run))); + Arena arena = runArenas.remove(run); + arena.close(); + } + + @Override + public int testCaseFromBlob(long settings, String blob, Consumer output, long[] out) { + // The blob replay's output is emitted synchronously during this call, so the stub only + // needs to live for its duration. + try (Arena arena = Arena.ofConfined()) { + MemorySegment callback = output == null ? MemorySegment.NULL : upcallStub(arena, output); + MemorySegment outSeg = arena.allocate(ADDRESS); + int code = rc(testCaseFromBlob, segment(settings), cstr(arena, blob), callback, MemorySegment.NULL, outSeg); + if (code == Abi.OK) { + out[0] = outSeg.get(ADDRESS, 0).address(); + } + return code; + } + } + + @Override + public void testCaseFree(long tc) { + check("hegel_test_case_free", rc(testCaseFree, segment(tc))); + } + + @Override + public boolean testCaseShouldCapture(long tc) { + MemorySegment out = Arena.ofAuto().allocate(JAVA_BOOLEAN); + check("hegel_test_case_should_capture", rc(testCaseShouldCapture, segment(tc), out)); + return out.get(JAVA_BOOLEAN, 0); + } + + @Override + public int testCaseClone(long tc, long[] out) { + MemorySegment seg = Arena.ofAuto().allocate(ADDRESS); + int code = rc(testCaseClone, segment(tc), seg); + if (code == Abi.OK) { + out[0] = seg.get(ADDRESS, 0).address(); + } + return code; + } + + @Override + public void testCaseSetWorker(long tc, long workerIndex) { + check("hegel_test_case_set_worker", rc(testCaseSetWorker, segment(tc), workerIndex)); + } + + // --- draws --- + + @Override + public int generateBoolean(long tc, double p, boolean[] out) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_BOOLEAN); + int code = rc(generateBoolean, segment(tc), p, false, false, seg); + if (code == Abi.OK) { + out[0] = seg.get(JAVA_BOOLEAN, 0); + } + return code; + } + + @Override + public int generateInteger(long tc, long min, long max, long[] out) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(generateInteger, segment(tc), min, max, seg); + if (code == Abi.OK) { + out[0] = seg.get(JAVA_LONG, 0); + } + return code; + } + + @Override + public int generateFloat( + long tc, + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude, + double[] out) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_DOUBLE); + int code = rc( + generateFloat, + segment(tc), + width, + min, + max, + allowNan, + allowInfinity, + excludeMin, + excludeMax, + smallestNonzeroMagnitude, + seg); + if (code == Abi.OK) { + out[0] = seg.get(JAVA_DOUBLE, 0); + } + return code; + } + + @Override + public int generateBytes(long tc, long minSize, long maxSize, byte[][] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment result = arena.allocate(BUFFER_RESULT_LAYOUT); + int code = rc(generateBytes, segment(tc), minSize, maxSize, result); + if (code == Abi.OK) { + out[0] = copyBuffer(result); + check("hegel_generate_bytes_result_free", rc(generateBytesResultFree, result)); + } + return code; + } + } + + @Override + public int generateString(long tc, long generator, String[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment result = arena.allocate(BUFFER_RESULT_LAYOUT); + int code = rc(generateString, segment(tc), segment(generator), result); + if (code == Abi.OK) { + out[0] = new String(copyBuffer(result), StandardCharsets.UTF_8); + check("hegel_generate_string_result_free", rc(generateStringResultFree, result)); + } + return code; + } + } + + /** Copy an engine-allocated {@code {data, len}} buffer out before it is freed. */ + private static byte[] copyBuffer(MemorySegment result) { + MemorySegment data = result.get(ADDRESS, 0); + long len = result.get(JAVA_LONG, ADDRESS.byteSize()); + return data.reinterpret(len).toArray(JAVA_BYTE); + } + + @Override + public int generateDate(long tc, LocalDate min, LocalDate max, LocalDate[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outSeg = arena.allocate(DATE_LAYOUT); + int code = rc(generateDate, segment(tc), dateSegment(arena, min), dateSegment(arena, max), outSeg); + if (code == Abi.OK) { + out[0] = readDate(outSeg, 0); + } + return code; + } + } + + @Override + public int generateTime(long tc, LocalTime min, LocalTime max, LocalTime[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outSeg = arena.allocate(TIME_LAYOUT); + int code = rc(generateTime, segment(tc), timeSegment(arena, min), timeSegment(arena, max), outSeg); + if (code == Abi.OK) { + out[0] = readTime(outSeg, 0); + } + return code; + } + } + + @Override + public int generateDatetime(long tc, LocalDateTime min, LocalDateTime max, LocalDateTime[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outSeg = arena.allocate(DATETIME_LAYOUT); + int code = + rc(generateDatetime, segment(tc), datetimeSegment(arena, min), datetimeSegment(arena, max), outSeg); + if (code == Abi.OK) { + out[0] = LocalDateTime.of(readDate(outSeg, 0), readTime(outSeg, DATE_LAYOUT.byteSize())); + } + return code; + } + } + + private static MemorySegment dateSegment(Arena arena, LocalDate date) { + MemorySegment seg = arena.allocate(DATE_LAYOUT); + writeDate(seg, 0, date); + return seg; + } + + private static MemorySegment timeSegment(Arena arena, LocalTime time) { + MemorySegment seg = arena.allocate(TIME_LAYOUT); + writeTime(seg, 0, time); + return seg; + } + + private static MemorySegment datetimeSegment(Arena arena, LocalDateTime dt) { + MemorySegment seg = arena.allocate(DATETIME_LAYOUT); + writeDate(seg, 0, dt.toLocalDate()); + writeTime(seg, DATE_LAYOUT.byteSize(), dt.toLocalTime()); + return seg; + } + + private static void writeDate(MemorySegment seg, long offset, LocalDate date) { + seg.set(JAVA_INT, offset, date.getYear()); + seg.set(JAVA_BYTE, offset + 4, (byte) date.getMonthValue()); + seg.set(JAVA_BYTE, offset + 5, (byte) date.getDayOfMonth()); + } + + private static LocalDate readDate(MemorySegment seg, long offset) { + return LocalDate.of(seg.get(JAVA_INT, offset), seg.get(JAVA_BYTE, offset + 4), seg.get(JAVA_BYTE, offset + 5)); + } + + private static void writeTime(MemorySegment seg, long offset, LocalTime time) { + seg.set(JAVA_BYTE, offset, (byte) time.getHour()); + seg.set(JAVA_BYTE, offset + 1, (byte) time.getMinute()); + seg.set(JAVA_BYTE, offset + 2, (byte) time.getSecond()); + seg.set(JAVA_INT, offset + 4, time.getNano()); + } + + private static LocalTime readTime(MemorySegment seg, long offset) { + return LocalTime.of( + seg.get(JAVA_BYTE, offset), + seg.get(JAVA_BYTE, offset + 1), + seg.get(JAVA_BYTE, offset + 2), + seg.get(JAVA_INT, offset + 4)); + } + + @Override + public int generateUuid(long tc, int version, boolean hasVersion, byte[] out16) { + return fixedBytesDraw(seg -> rc(generateUuid, segment(tc), (byte) version, hasVersion, seg), out16); + } + + @Override + public int generateIpv4(long tc, byte[] out4) { + return fixedBytesDraw(seg -> rc(generateIpv4, segment(tc), seg), out4); + } + + @Override + public int generateIpv6(long tc, byte[] out16) { + return fixedBytesDraw(seg -> rc(generateIpv6, segment(tc), seg), out16); + } + + @FunctionalInterface + private interface BytesDraw { + int run(MemorySegment out); + } + + /** Run a draw writing into a fixed-size byte buffer, copying it out on success. */ + private static int fixedBytesDraw(BytesDraw draw, byte[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment seg = arena.allocate(out.length); + int code = draw.run(seg); + if (code == Abi.OK) { + MemorySegment.copy(seg, JAVA_BYTE, 0, out, 0, out.length); + } + return code; + } + } + + // --- string-generator handles --- + + @Override + public int stringGeneratorText( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters, + long[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment categoriesSeg = cstrArray(arena, categories); + MemorySegment excludeSeg = cstrArray(arena, excludeCategories); + byte[] include = utf8OrNull(includeCharacters); + byte[] exclude = utf8OrNull(excludeCharacters); + MemorySegment outSeg = arena.allocate(ADDRESS); + int code = rc( + stringGeneratorText, + minSize, + maxSize, + cstr(arena, codec), + (int) minCodepoint, + (int) maxCodepoint, + categoriesSeg, + categories == null ? 0L : (long) categories.size(), + excludeSeg, + excludeCategories == null ? 0L : (long) excludeCategories.size(), + bytesOrNull(arena, include), + include == null ? 0L : (long) include.length, + bytesOrNull(arena, exclude), + exclude == null ? 0L : (long) exclude.length, + outSeg); + // Read the (zero-initialised) out slot unconditionally: callers check the return code + // before using it. + out[0] = outSeg.get(ADDRESS, 0).address(); + return code; + } + } + + private static byte[] utf8OrNull(String s) { + return s == null ? null : s.getBytes(StandardCharsets.UTF_8); + } + + private static MemorySegment bytesOrNull(Arena arena, byte[] bytes) { + if (bytes == null) { + return MemorySegment.NULL; + } + MemorySegment seg = arena.allocate(Math.max(bytes.length, 1)); + MemorySegment.copy(bytes, 0, seg, JAVA_BYTE, 0, bytes.length); + return seg; + } + + /** A NULL-distinct {@code char**}: {@code null} maps to NULL, an empty list to a valid pointer. */ + private static MemorySegment cstrArray(Arena arena, List strings) { + if (strings == null) { + return MemorySegment.NULL; + } + MemorySegment array = arena.allocate(ADDRESS, Math.max(strings.size(), 1)); + for (int i = 0; i < strings.size(); i++) { + array.setAtIndex(ADDRESS, i, cstr(arena, strings.get(i))); + } + return array; + } + + @Override + public int stringGeneratorRegex(String pattern, boolean fullmatch, long alphabet, long[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outSeg = arena.allocate(ADDRESS); + int code = rc(stringGeneratorRegex, cstr(arena, pattern), fullmatch, segment(alphabet), outSeg); + out[0] = outSeg.get(ADDRESS, 0).address(); + return code; + } + } + + @Override + public int stringGeneratorEmail(long[] out) { + return handleConstructor(stringGeneratorEmail, out); + } + + @Override + public int stringGeneratorUrl(long[] out) { + return handleConstructor(stringGeneratorUrl, out); + } + + /** Run a no-argument string-generator constructor. */ + private int handleConstructor(MethodHandle constructor, long[] out) { + MemorySegment outSeg = Arena.ofAuto().allocate(ADDRESS); + int code = rc(constructor, outSeg); + out[0] = outSeg.get(ADDRESS, 0).address(); + return code; + } + + @Override + public int stringGeneratorDomain(long maxLength, long[] out) { + MemorySegment outSeg = Arena.ofAuto().allocate(ADDRESS); + int code = rc(stringGeneratorDomain, maxLength, outSeg); + out[0] = outSeg.get(ADDRESS, 0).address(); + return code; + } + + @Override + public void stringGeneratorFree(long generator) { + check("hegel_string_generator_free", rc(stringGeneratorFree, segment(generator))); + } + + // --- structure --- + + @Override + public int startSpan(long tc, long label) { + return rc(startSpan, segment(tc), label); + } + + @Override + public int stopSpan(long tc, boolean discard) { + return rc(stopSpan, segment(tc), discard); + } + + @Override + public int newCollection(long tc, long minSize, long maxSize, long[] outId) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(newCollection, segment(tc), minSize, maxSize, seg); + outId[0] = seg.get(JAVA_LONG, 0); + return code; + } + + @Override + public int collectionMore(long tc, long id, boolean[] outMore) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_BOOLEAN); + int code = rc(collectionMore, segment(tc), id, seg); + if (code == Abi.OK) { + outMore[0] = seg.get(JAVA_BOOLEAN, 0); + } + return code; + } + + @Override + public int collectionReject(long tc, long id, String why) { + return rc(collectionReject, segment(tc), id, cstr(Arena.ofAuto(), why)); + } + + @Override + public int newPool(long tc, long[] outId) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(newPool, segment(tc), seg); + outId[0] = seg.get(JAVA_LONG, 0); + return code; + } + + @Override + public int poolAdd(long tc, long poolId, long[] outVariableId) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(poolAdd, segment(tc), poolId, seg); + outVariableId[0] = seg.get(JAVA_LONG, 0); + return code; + } + + @Override + public int poolGenerate(long tc, long poolId, boolean consume, long[] outVariableId) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(poolGenerate, segment(tc), poolId, consume, seg); + outVariableId[0] = seg.get(JAVA_LONG, 0); + return code; + } + + @Override + public int newStateMachine( + long tc, + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + long stepCount, + long[] outId, + long[] outConcurrency) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment rules = cstrArray(arena, ruleNames); + MemorySegment groups = arena.allocate(JAVA_LONG, Math.max(ruleGroups.length, 1)); + for (int i = 0; i < ruleGroups.length; i++) { + groups.setAtIndex(JAVA_LONG, i, ruleGroups[i]); + } + // NULL keeps every rule at the same weight. + MemorySegment weights = MemorySegment.NULL; + if (ruleWeights != null) { + weights = arena.allocate(JAVA_DOUBLE, Math.max(ruleWeights.length, 1)); + for (int i = 0; i < ruleWeights.length; i++) { + weights.setAtIndex(JAVA_DOUBLE, i, ruleWeights[i]); + } + } + MemorySegment invariants = cstrArray(arena, invariantNames); + MemorySegment alwaysCheck = arena.allocate(JAVA_BOOLEAN, Math.max(invariantAlwaysCheck.length, 1)); + for (int i = 0; i < invariantAlwaysCheck.length; i++) { + alwaysCheck.setAtIndex(JAVA_BOOLEAN, i, invariantAlwaysCheck[i]); + } + MemorySegment id = arena.allocate(JAVA_LONG); + MemorySegment concurrency = arena.allocate(JAVA_LONG); + int code = rc( + newStateMachine, + segment(tc), + rules, + groups, + weights, + (long) ruleNames.size(), + invariants, + alwaysCheck, + (long) invariantNames.size(), + minConcurrency, + maxConcurrency, + stepCount, + id, + concurrency); + if (code == Abi.OK) { + outId[0] = id.get(JAVA_LONG, 0); + outConcurrency[0] = concurrency.get(JAVA_LONG, 0); + } + return code; + } + } + + @Override + public int stateMachineNextGroup(long tc, long stateMachineId, long[] outGroupId) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(stateMachineNextGroup, segment(tc), stateMachineId, seg); + if (code == Abi.OK) { + outGroupId[0] = seg.get(JAVA_LONG, 0); + } + return code; + } + + @Override + public int stateMachineNextRule(long tc, long stateMachineId, long workerIndex, long[] outRuleIndex) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + int code = rc(stateMachineNextRule, segment(tc), stateMachineId, workerIndex, seg); + if (code == Abi.OK) { + outRuleIndex[0] = seg.get(JAVA_LONG, 0); + } + return code; + } + + @Override + public int stateMachineRuleRejected(long tc, long stateMachineId, long workerIndex) { + return rc(stateMachineRuleRejected, segment(tc), stateMachineId, workerIndex); + } + + @Override + public int stateMachineShouldCheckInvariant( + long tc, long stateMachineId, long invariantIndex, boolean[] outShouldCheck) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_BOOLEAN); + int code = rc(stateMachineShouldCheckInvariant, segment(tc), stateMachineId, invariantIndex, seg); + if (code == Abi.OK) { + outShouldCheck[0] = seg.get(JAVA_BOOLEAN, 0); + } + return code; + } + + @Override + public void stateMachineFree(long stateMachineId) { + check("hegel_state_machine_free", rc(stateMachineFree, stateMachineId)); + } + + @Override + public int target(long tc, double value, String label) { + return rc(target, segment(tc), value, cstr(Arena.ofAuto(), label)); + } + + @Override + public int markComplete(long tc, int status, String origin) { + return rc(markComplete, segment(tc), status, cstr(Arena.ofAuto(), origin)); + } + + // --- results --- + + @Override + public int runResultStatus(long result) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_INT); + check("hegel_run_result_status", rc(runResultStatus, segment(result), seg)); + return seg.get(JAVA_INT, 0); + } + + @Override + public String runResultError(long result) { + MemorySegment seg = Arena.ofAuto().allocate(ADDRESS); + check("hegel_run_result_error", rc(runResultError, segment(result), seg)); + return readCString(seg.get(ADDRESS, 0)); + } + + @Override + public long runResultFailureCount(long result) { + MemorySegment seg = Arena.ofAuto().allocate(JAVA_LONG); + check("hegel_run_result_failure_count", rc(runResultFailureCount, segment(result), seg)); + return seg.get(JAVA_LONG, 0); + } + + @Override + public String failureBlob(long result, long index) { + return readFailureString(result, index, failureReproductionBlob, "hegel_failure_reproduction_blob"); + } + + @Override + public String failureOrigin(long result, long index) { + return readFailureString(result, index, failureOrigin, "hegel_failure_origin"); + } + + @Override + public String failureCaveat(long result, long index) { + return readFailureString(result, index, failureCaveat, "hegel_failure_caveat"); + } + + /** Fetch the {@code index}-th failure, read one of its strings with {@code reader}, free it. */ + private String readFailureString(long result, long index, MethodHandle reader, String op) { + MemorySegment failureOut = Arena.ofAuto().allocate(ADDRESS); + check("hegel_run_result_failure", rc(runResultFailure, segment(result), index, failureOut)); + MemorySegment failure = failureOut.get(ADDRESS, 0); + MemorySegment strOut = Arena.ofAuto().allocate(ADDRESS); + check(op, rc(reader, failure, strOut)); + String value = readCString(strOut.get(ADDRESS, 0)); + check("hegel_failure_free", rc(failureFree, failure)); + return value; + } + + // --- diagnostics --- + + @Override + public String lastErrorMessage() { + return readCString((MemorySegment) invoke(contextLastError, ctx())); + } + + @Override + public String version() { + MemorySegment seg = Arena.ofAuto().allocate(ADDRESS); + check("hegel_version", rc(version, seg)); + return readCString(seg.get(ADDRESS, 0)); + } +} diff --git a/hegel/src/main/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend b/hegel/src/main/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend new file mode 100644 index 0000000..9f8655e --- /dev/null +++ b/hegel/src/main/resources/META-INF/services/dev.hegel.lowlevel.LibhegelBackend @@ -0,0 +1 @@ +dev.hegel.FfmBackend diff --git a/hegel/src/test/java/dev/hegel/FfmCoverageTest.java b/hegel/src/test/java/dev/hegel/FfmCoverageTest.java new file mode 100644 index 0000000..f77613f --- /dev/null +++ b/hegel/src/test/java/dev/hegel/FfmCoverageTest.java @@ -0,0 +1,32 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** Targeted tests closing FFM-binding coverage branches the engine path does not reach. */ +class FfmCoverageTest { + // --- output-callback bridge --- + @Test + void emitLineDecodesAndSwallowsExceptions() { + try (Arena arena = Arena.ofConfined()) { + byte[] bytes = "hello".getBytes(StandardCharsets.UTF_8); + MemorySegment line = arena.allocate(bytes.length); + MemorySegment.copy(bytes, 0, line, ValueLayout.JAVA_BYTE, 0, bytes.length); + AtomicReference got = new AtomicReference<>(); + RealLibhegel.emitLine(got::set, MemorySegment.NULL, line, bytes.length); + assertEquals("hello", got.get()); + // A throwing consumer must be swallowed: an exception escaping an upcall kills the VM. + Consumer throwing = s -> { + throw new IllegalStateException("never escapes"); + }; + RealLibhegel.emitLine(throwing, MemorySegment.NULL, line, bytes.length); + } + } +} diff --git a/hegel/src/test/java/dev/hegel/RealLibhegelTest.java b/hegel/src/test/java/dev/hegel/RealLibhegelTest.java new file mode 100644 index 0000000..52c84a1 --- /dev/null +++ b/hegel/src/test/java/dev/hegel/RealLibhegelTest.java @@ -0,0 +1,255 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Covers {@link RealLibhegel} edge branches that the normal engine path does not reach. */ +class RealLibhegelTest { + + static void throwsError() { + throw new AssertionError("boom"); // an Error (Throwable, not RuntimeException) + } + + static void throwsRuntime() { + throw new IllegalStateException("rt"); + } + + @Test + void invokeWrapsNonRuntimeThrowable() throws Exception { + MethodHandle h = MethodHandles.lookup() + .findStatic(RealLibhegelTest.class, "throwsError", MethodType.methodType(void.class)); + assertThrows(HegelException.class, () -> RealLibhegel.invoke(h)); + } + + @Test + void invokeRethrowsRuntimeException() throws Exception { + MethodHandle h = MethodHandles.lookup() + .findStatic(RealLibhegelTest.class, "throwsRuntime", MethodType.methodType(void.class)); + assertThrows(IllegalStateException.class, () -> RealLibhegel.invoke(h)); + } + + @Test + void readCStringHandlesNullAndValue() { + assertNull(RealLibhegel.readCString(null)); + assertNull(RealLibhegel.readCString(MemorySegment.NULL)); + try (Arena a = Arena.ofConfined()) { + assertEquals("hello", RealLibhegel.readCString(a.allocateFrom("hello"))); + } + } + + @Test + void findSymbolReturnsPresentAndThrowsOnMissing() { + Path lib = LibraryLoader.fromEnvironment().resolve(); + try (Arena a = Arena.ofShared()) { + SymbolLookup lookup = SymbolLookup.libraryLookup(lib, a); + assertNotNull(RealLibhegel.findSymbol(lookup, "hegel_version")); + assertThrows(HegelException.class, () -> RealLibhegel.findSymbol(lookup, "no_such_symbol_xyz")); + } + } + + @Test + void constructorRejectsBadPath() { + assertThrows(HegelException.class, () -> new RealLibhegel(Path.of("/nonexistent/libhegel.so"))); + } + + private static RealLibhegel real() { + return new RealLibhegel(LibraryLoader.fromEnvironment().resolve()); + } + + @Test + void infrastructureCallsReportNullHandles() { + RealLibhegel lib = real(); + // A NULL handle on an infra call surfaces as a HegelException carrying the engine's + // diagnostic rather than undefined behaviour. + assertThrows(HegelException.class, () -> lib.runResultStatus(0)); + assertThrows(HegelException.class, () -> lib.runStart(0, null)); + assertThrows(HegelException.class, () -> lib.runStartBlob(0, "blob", null)); + assertThrows(HegelException.class, () -> lib.testCaseShouldCapture(0)); + assertThrows(HegelException.class, () -> lib.testCaseSetWorker(0, 0)); + long[] clone = {7}; + assertEquals(Abi.E_INVALID_HANDLE, lib.testCaseClone(0, clone)); + assertEquals(7, clone[0]); + } + + @Test + void structuredDrawsReportNullHandles() { + RealLibhegel lib = real(); + java.time.LocalDate d = java.time.LocalDate.of(2000, 1, 1); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateDate(0, d, d, new java.time.LocalDate[1])); + java.time.LocalTime t = java.time.LocalTime.NOON; + assertEquals(Abi.E_INVALID_HANDLE, lib.generateTime(0, t, t, new java.time.LocalTime[1])); + java.time.LocalDateTime dt = java.time.LocalDateTime.of(d, t); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateDatetime(0, dt, dt, new java.time.LocalDateTime[1])); + } + + @Test + void booleanDrawReportsNullHandle() { + RealLibhegel lib = real(); + // Seeded true because the draw's scratch segment is arena-allocated and therefore zeroed: + // a copy-out on this failed call would read back false rather than leave the value alone. + boolean[] out = {true}; + assertEquals(Abi.E_INVALID_HANDLE, lib.generateBoolean(0, 0.5, out)); + assertTrue(out[0]); + } + + @Test + void fixedBytesDrawsReportNullHandles() { + RealLibhegel lib = real(); + // A NULL test case is rejected before the engine writes anything, so fixedBytesDraw never + // reaches its copy-out and each buffer keeps the caller's bytes. + byte[] uuid = sentinel(16); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateUuid(0, 0, false, uuid)); + assertArrayEquals(sentinel(16), uuid); + + byte[] ipv4 = sentinel(4); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateIpv4(0, ipv4)); + assertArrayEquals(sentinel(4), ipv4); + + byte[] ipv6 = sentinel(16); + assertEquals(Abi.E_INVALID_HANDLE, lib.generateIpv6(0, ipv6)); + assertArrayEquals(sentinel(16), ipv6); + } + + /** + * A buffer of non-zero bytes. The draw's scratch segment is arena-allocated and therefore + * zeroed, so an unwanted copy-out would blank the buffer rather than leave it as it was. + */ + private static byte[] sentinel(int length) { + byte[] b = new byte[length]; + java.util.Arrays.fill(b, (byte) 0x7f); + return b; + } + + @Test + void stateMachineCallsReportNullHandle() { + RealLibhegel lib = real(); + // Both handles are NULL: the engine rejects the call on the test case before it + // dereferences the state machine, so this is a clean error rather than undefined behaviour. + // Out-parameters are read only on success, so a failed call leaves the caller's values alone. + long[] out = {7}; + long[] concurrency = {9}; + boolean[] check = {true}; + assertEquals( + Abi.E_INVALID_HANDLE, + lib.newStateMachine( + 0, + List.of("r"), + new long[] {0}, + null, + List.of("i"), + new boolean[] {true}, + 1, + 1, + 50, + out, + concurrency)); + assertEquals(7, out[0]); + assertEquals(9, concurrency[0]); + // Explicit weights are marshalled too (NULL above keeps the engine's all-equal default). + assertEquals( + Abi.E_INVALID_HANDLE, + lib.newStateMachine( + 0, + List.of("r"), + new long[] {0}, + new double[] {2.5}, + List.of("i"), + new boolean[] {true}, + 1, + 1, + 50, + out, + concurrency)); + assertEquals(7, out[0]); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineNextGroup(0, 0, out)); + assertEquals(7, out[0]); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineNextRule(0, 0, 0, out)); + assertEquals(7, out[0]); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineRuleRejected(0, 0, 0)); + assertEquals(Abi.E_INVALID_HANDLE, lib.stateMachineShouldCheckInvariant(0, 0, 0, check)); + assertTrue(check[0]); + // Freeing NULL is a documented no-op. + lib.stateMachineFree(0); + } + + @Test + void settingsGettersReadTheHandleBack() { + RealLibhegel lib = real(); + long s = newSettings(lib); + lib.settingsTestCases(s, 7); + assertEquals(7, lib.settingsGetTestCases(s)); + lib.settingsPrintBlob(s, false); + assertFalse(lib.settingsGetPrintBlob(s)); + lib.settingsPrintBlob(s, true); + assertTrue(lib.settingsGetPrintBlob(s)); + lib.settingsNondeterminismStrictness(s, Abi.NONDETERMINISM_ERROR); + assertEquals(Abi.NONDETERMINISM_ERROR, lib.settingsGetNondeterminismStrictness(s)); + lib.settingsNondeterminismStrictness(s, Abi.NONDETERMINISM_WARN); + assertEquals(Abi.NONDETERMINISM_WARN, lib.settingsGetNondeterminismStrictness(s)); + lib.settingsFree(s); + } + + private static long newSettings(RealLibhegel lib) { + long[] out = new long[1]; + assertEquals(Abi.OK, lib.settingsNew(out)); + return out[0]; + } + + @Test + void undecodableBlobWithDefaultOutputIsRejected() { + RealLibhegel lib = real(); + long s = newSettings(lib); + long[] out = new long[1]; + // A null output callback leaves replay output on stderr; the garbage blob is rejected. + assertEquals(Abi.E_INVALID_ARG, lib.testCaseFromBlob(s, "not-a-blob!!!", null, out)); + lib.settingsFree(s); + } + + @Test + void blobRunWithDefaultOutputStartsAndReportsTheBadBlob() { + RealLibhegel lib = real(); + long s = newSettings(lib); + // A null output callback leaves the run's output on stderr; the garbage blob surfaces as + // the run's error once it is pumped dry. + long run = lib.runStartBlob(s, "not-a-blob!!!", null); + assertNotEquals(0, run); + assertEquals(0, lib.nextTestCase(run)); + long result = lib.runResult(run); + assertEquals(Abi.RUN_STATUS_ERROR, lib.runResultStatus(result)); + assertNotNull(lib.runResultError(result)); + lib.runResultFree(result); + lib.runFree(run); + lib.settingsFree(s); + } + + @Test + void regexGeneratorAcceptsATextAlphabet() { + RealLibhegel lib = real(); + long[] alphabet = new long[1]; + assertEquals( + Abi.OK, + lib.stringGeneratorText(0, 5, "ascii", 0, Abi.NO_MAX_CODEPOINT, null, null, null, null, alphabet)); + long[] regex = new long[1]; + assertEquals(Abi.OK, lib.stringGeneratorRegex("[a-z]+", true, alphabet[0], regex)); + lib.stringGeneratorFree(regex[0]); + lib.stringGeneratorFree(alphabet[0]); + } +} diff --git a/justfile b/justfile index 3c21329..4daff41 100644 --- a/justfile +++ b/justfile @@ -1,10 +1,18 @@ +# Recipes assume a POSIX shell; on Windows run them under Git Bash. +set windows-shell := ["bash", "-uc"] + build-libhegel: #!/usr/bin/env bash set -euo pipefail if [ -d ../hegel-rust ]; then (cd ../hegel-rust && cargo build --release -p hegeltest-c) + case "$(uname -s)" in + Darwin*) lib=libhegel.dylib ;; + MINGW*|MSYS*|CYGWIN*) lib=hegel.dll ;; # cargo emits no lib prefix on Windows + *) lib=libhegel.so ;; + esac echo "Built libhegel. Point the tests at it with:" - echo " export HEGEL_LIBHEGEL_PATH=$(cd ../hegel-rust && pwd)/target/release/libhegel.\$(uname -s | grep -qi darwin && echo dylib || echo so)" + echo " export HEGEL_LIBHEGEL_PATH=$(cd ../hegel-rust && pwd)/target/release/$lib" else echo "No sibling ../hegel-rust checkout; tests use the libhegel bundled in the jar." fi @@ -18,6 +26,12 @@ test: coverage: mvn -B verify +test-jna: + mvn -B -pl hegel-jna -am test + +coverage-jna: + mvn -B -pl hegel-jna -am verify + conformance: mvn -B test -Dtest='*Conformance*,*Behaviour*' @@ -27,12 +41,14 @@ format: lint: mvn -B spotless:check +# `compile` first: hegel and hegel-jna depend on hegel-lowlevel, and a bare `javadoc:javadoc` +# would try to resolve it from Maven Central instead of the reactor. Javadoc needs no natives. check-docs: - mvn -B -q javadoc:javadoc + mvn -B -q -Dhegel.natives.skip=true compile javadoc:javadoc docs: - mvn -B -q javadoc:javadoc - open target/reports/apidocs/index.html + mvn -B -q -Dhegel.natives.skip=true compile javadoc:javadoc + open hegel/target/reports/apidocs/index.html clean: mvn -B -q clean diff --git a/pom.xml b/pom.xml index d0aa513..b579f29 100644 --- a/pom.xml +++ b/pom.xml @@ -5,11 +5,11 @@ 4.0.0 dev.hegel - hegel - 0.1.0 - jar + hegel-parent + 0.10.0 + pom - hegel-java + hegel-java parent Property-based testing for Java, built on Hypothesis https://github.com/hegeldev/hegel-java @@ -42,30 +42,33 @@ https://github.com/hegeldev/hegel-java/issues + + hegel-lowlevel + hegel + hegel-jna + + UTF-8 22 5.11.4 - 4.5.6 0.8.14 - - 0.14.14 + 0.44.0 false - + + python3 + --enable-native-access=ALL-UNNAMED + ${maven.multiModuleProjectDirectory}/shared - - com.upokecenter - cbor - ${cbor.version} - - @@ -92,171 +95,238 @@ - - - org.apache.maven.plugins - maven-compiler-plugin - 3.13.0 - + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + - - - org.apache.maven.plugins - maven-resources-plugin - 3.3.1 - - - filter-java-templates - generate-sources - - copy-resources - - - ${project.build.directory}/generated-sources/java-templates - - - src/main/java-templates - true - - - - - - + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + - - - org.codehaus.mojo - build-helper-maven-plugin - 3.6.0 - - - add-generated-sources - generate-sources - - add-source - - - - ${project.build.directory}/generated-sources/java-templates - - - - - + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + filter-java-templates + generate-sources + + copy-resources + + + ${project.build.directory}/generated-sources/java-templates + + + ${project.basedir}/src/main/java-templates + true + + + + + + - - - org.codehaus.mojo - exec-maven-plugin - 3.5.0 - - - fetch-natives - generate-resources - - exec - - - ${hegel.natives.skip} - python3 - - ${project.basedir}/scripts/fetch_natives.py - --version - ${libhegel.version} - --out - ${project.build.outputDirectory} - - - - - + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-shared-sources + generate-sources + + add-source + + + + ${hegel.shared}/src/main/java + ${project.build.directory}/generated-sources/java-templates + + + + + add-shared-test-sources + generate-test-sources + + add-test-source + + + + ${hegel.shared}/src/test/java + + + + + - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.2 - - @{argLine} ${hegel.argLine} - - + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + fetch-natives + generate-resources + + exec + + + ${hegel.natives.skip} + ${hegel.python} + + ${maven.multiModuleProjectDirectory}/scripts/fetch_natives.py + --version + ${libhegel.version} + --out + ${project.build.outputDirectory} + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + @{argLine} ${hegel.argLine} + + + + + org.apache.maven.plugins + maven-invoker-plugin + 3.9.0 + + src/it + ${project.build.directory}/it + true + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + + 2.91.0 + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + all,-missing + true + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + check + verify + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 1.00 + + + BRANCH + COVEREDRATIO + 1.00 + + + + + + + + + + + + com.diffplug.spotless spotless-maven-plugin - 2.46.1 + false + + shared/src/main/java/**/*.java + hegel-lowlevel/src/main/java-templates/**/*.java + shared/src/test/java/**/*.java + 2.91.0 - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.11.2 - - all,-missing - true - - - - - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - prepare-agent - - prepare-agent - - - - report - verify - - report - - - - check - verify - - check - - - - - BUNDLE - - - INSTRUCTION - COVEREDRATIO - 1.00 - - - BRANCH - COVEREDRATIO - 1.00 - - - - - - - - + + + windows-python + + + windows + + + + python + + + release @@ -304,6 +374,15 @@ sign + + true --pinentry-mode diff --git a/scripts/fetch_natives.py b/scripts/fetch_natives.py index f211d41..150d220 100644 --- a/scripts/fetch_natives.py +++ b/scripts/fetch_natives.py @@ -31,8 +31,9 @@ import urllib.request from pathlib import Path -# Asset names look like ``libhegel-linux-amd64.so`` / ``libhegel-darwin-arm64.dylib``. -ASSET_RE = re.compile(r"^libhegel-([A-Za-z0-9]+)-([A-Za-z0-9]+)\.(so|dylib)$") +# Asset names look like ``libhegel-linux-amd64.so`` / ``libhegel-darwin-arm64.dylib`` / +# ``libhegel-windows-amd64.dll``. +ASSET_RE = re.compile(r"^libhegel-([A-Za-z0-9]+)-([A-Za-z0-9]+)\.(so|dylib|dll)$") DEFAULT_REPO = "hegeldev/hegel-rust" @@ -57,9 +58,19 @@ def http_get(url: str) -> bytes: return resp.read() +def release_tag(version: str) -> str: + """The git tag of the libhegel release for ``version``. + + Since libhegel 0.42.1 a release is tagged ``libhegel-v`` (the plain ``v`` + tags belong to the ``hegeltest`` crate, whose version differs). Earlier releases carry both + tags, so this one scheme resolves every version. + """ + return f"libhegel-v{version}" + + def discover_assets(repo: str, version: str) -> dict[str, str]: - """Return ``{asset_name: download_url}`` for the release tagged ``v``.""" - api = f"https://api.github.com/repos/{repo}/releases/tags/v{version}" + """Return ``{asset_name: download_url}`` for the release tagged ``libhegel-v``.""" + api = f"https://api.github.com/repos/{repo}/releases/tags/{release_tag(version)}" release = json.loads(http_get(api)) return {a["name"]: a["browser_download_url"] for a in release.get("assets", [])} @@ -76,7 +87,7 @@ def populate_cache(repo: str, version: str, cache: Path) -> None: assets = discover_assets(repo, version) libs = {name: url for name, url in assets.items() if ASSET_RE.match(name)} if not libs: - log(f"release v{version} of {repo} publishes no libhegel shared objects") + log(f"release {release_tag(version)} of {repo} publishes no libhegel shared objects") return cache.mkdir(parents=True, exist_ok=True) for name, url in sorted(libs.items()): @@ -132,7 +143,7 @@ def main() -> int: libs = cached_libs(cache) if not libs: - log(f"release v{args.version} of {args.repo} staged no libhegel shared objects") + log(f"release {release_tag(args.version)} of {args.repo} staged no libhegel shared objects") return 1 stage(libs, args.out) return 0 diff --git a/src/main/java/dev/hegel/AssumeRejected.java b/shared/src/main/java/dev/hegel/AssumeRejected.java similarity index 100% rename from src/main/java/dev/hegel/AssumeRejected.java rename to shared/src/main/java/dev/hegel/AssumeRejected.java diff --git a/shared/src/main/java/dev/hegel/Backend.java b/shared/src/main/java/dev/hegel/Backend.java new file mode 100644 index 0000000..be9b594 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Backend.java @@ -0,0 +1,34 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; + +/** + * The source of randomness the engine draws from. + * + *

    Mirrors Hypothesis's {@code backend} setting. The default, {@link #AUTO}, leaves the choice to + * the engine's settings profile: the shipped {@code workload} profile, which the engine selects when + * running inside Antithesis, uses {@link #URANDOM}, and every + * other profile uses {@link #DEFAULT}. An explicit choice always wins over the profile's. + */ +public enum Backend { + /** Leave the choice to the engine's profile: {@code URANDOM} under Antithesis, else {@code DEFAULT}. */ + AUTO(null), + /** + * Expand a single seeded PRNG. Runs are reproducible from the seed and shrinking and replay + * work as usual. + */ + DEFAULT(Abi.BACKEND_DEFAULT), + /** + * Read fresh entropy from {@code /dev/urandom} on every draw. Intended for running under + * Antithesis, whose fuzzer controls {@code /dev/urandom}, handing it control over the entire + * test case; you almost certainly don't want it otherwise. + */ + URANDOM(Abi.BACKEND_URANDOM); + + /** The {@code hegel_backend_t} value to send, or {@code null} to leave the profile's choice. */ + final Integer code; + + Backend(Integer code) { + this.code = code; + } +} diff --git a/shared/src/main/java/dev/hegel/CaseOutcome.java b/shared/src/main/java/dev/hegel/CaseOutcome.java new file mode 100644 index 0000000..16a5f89 --- /dev/null +++ b/shared/src/main/java/dev/hegel/CaseOutcome.java @@ -0,0 +1,22 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; + +/** How a single test case concluded, as reported to the engine. */ +public enum CaseOutcome { + /** The body returned normally: the property held for this input. */ + VALID(Abi.STATUS_VALID), + /** The body rejected the input with {@link TestCase#assume(boolean)}. */ + INVALID(Abi.STATUS_INVALID), + /** The body drew more data than the engine allowed for this case. */ + OVERRUN(Abi.STATUS_OVERRUN), + /** The body threw: the property failed for this input. */ + INTERESTING(Abi.STATUS_INTERESTING); + + /** The {@code hegel_status_t} value passed to {@code hegel_mark_complete}. */ + final int status; + + CaseOutcome(int status) { + this.status = status; + } +} diff --git a/shared/src/main/java/dev/hegel/ConcurrentPool.java b/shared/src/main/java/dev/hegel/ConcurrentPool.java new file mode 100644 index 0000000..ee4c84f --- /dev/null +++ b/shared/src/main/java/dev/hegel/ConcurrentPool.java @@ -0,0 +1,97 @@ +package dev.hegel; + +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link Pool} for concurrent state machines: previously generated values that rules running on + * different worker threads can add, reuse and consume, with the engine choosing and shrinking over + * which value a rule gets. + * + *

    Create one per test case on the test case the machine is run under, and have rules go through + * the {@link TestCase} they were handed: {@link #add(TestCase, Object)} records a value through the + * calling rule's handle, and the generators from {@link #reusable()} and {@link #consuming()} draw + * through whichever handle draws them. Every operation holds the pool's lock across the engine + * call and the bookkeeping, so the engine's choice and the Java-side values never disagree even + * when workers race. Drawing from an empty pool rejects the current rule (as if by {@code + * assume(false)}), so the engine retries the slot with another rule. + * + *

    A {@link #reusable()} draw hands out the stored reference itself; do not mutate a pooled value + * from a rule, since another worker may be reading it at the same time. + * + * @param the type of pooled values + * @see Stateful + */ +public final class ConcurrentPool { + private final long poolId; + private final Map values = new HashMap<>(); + + /** + * Create a pool tracked by the current test case. + * + * @param tc the test case the machine is run under + */ + public ConcurrentPool(TestCase tc) { + this.poolId = tc.newPool(); + } + + /** + * @return whether no values are in the pool + */ + public synchronized boolean isEmpty() { + return values.isEmpty(); + } + + /** + * @return the number of values currently in the pool + */ + public synchronized int size() { + return values.size(); + } + + /** + * Add a value to the pool. + * + * @param tc the test case of the rule adding the value: its own worker's handle + * @param value the value to add + */ + public synchronized void add(TestCase tc, T value) { + values.put(tc.poolAdd(poolId), value); + } + + /** + * A generator over the values in the pool that yields a value without removing it. + * + * @return the reusing generator + */ + public Generator reusable() { + return new PoolGenerator(false); + } + + /** + * A generator that consumes values from the pool: it removes the value it yields, so once + * consumed a value is never drawn again. + * + * @return the consuming generator + */ + public Generator consuming() { + return new PoolGenerator(true); + } + + private final class PoolGenerator implements Generator { + private final boolean consume; + + PoolGenerator(boolean consume) { + this.consume = consume; + } + + @Override + public T doDraw(TestCase tc) { + synchronized (ConcurrentPool.this) { + tc.assume(!values.isEmpty()); + long variableId = tc.poolGenerate(poolId, consume); + return consume ? values.remove(variableId) : values.get(variableId); + } + } + } +} diff --git a/shared/src/main/java/dev/hegel/DataSource.java b/shared/src/main/java/dev/hegel/DataSource.java new file mode 100644 index 0000000..6712da4 --- /dev/null +++ b/shared/src/main/java/dev/hegel/DataSource.java @@ -0,0 +1,147 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Libhegel; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +/** + * The per-test-case primitive surface that generators draw against. + * + *

    Generators depend on this interface rather than {@link Libhegel} directly, so they can be + * tested against a fake data source. Every method translates engine return codes: {@code STOP_TEST} + * becomes {@link StopTest}, an assumption rejection becomes {@link AssumeRejected}, an invalid + * argument becomes {@link IllegalArgumentException} carrying the engine's diagnostic, and any other + * non-OK code becomes a {@link HegelException}. + */ +interface DataSource { + boolean generateBoolean(double p); + + long generateInteger(long min, long max); + + double generateFloat( + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude); + + byte[] generateBytes(long minSize, long maxSize); + + String generateString(StringGeneratorHandle generator); + + LocalDate generateDate(LocalDate min, LocalDate max); + + LocalTime generateTime(LocalTime min, LocalTime max); + + LocalDateTime generateDatetime(LocalDateTime min, LocalDateTime max); + + UUID generateUuid(Integer version); + + byte[] generateIpv4(); + + byte[] generateIpv6(); + + // String-generator handle construction. Parameters are validated eagerly: a rejected + // configuration throws IllegalArgumentException with the engine's diagnostic. + StringGeneratorHandle textGenerator( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters); + + StringGeneratorHandle regexGenerator(String pattern, boolean fullmatch, StringGeneratorHandle alphabet); + + StringGeneratorHandle emailGenerator(); + + StringGeneratorHandle urlGenerator(); + + StringGeneratorHandle domainGenerator(long maxLength); + + /** + * Whether {@code generator} was built by the binding behind this source. A cached handle from + * another binding (e.g. after a test swapped the {@link Engine}) must be rebuilt, not drawn + * from. + */ + boolean ownsStringGenerator(StringGeneratorHandle generator); + + void startSpan(long label); + + void stopSpan(boolean discard); + + long newCollection(long minSize, long maxSize); + + boolean collectionMore(long id); + + void collectionReject(long id, String why); + + long newPool(); + + long poolAdd(long poolId); + + long poolGenerate(long poolId, boolean consume); + + // Stateful testing. The root source registers the machine, advances rounds and samples + // invariants; at concurrency > 1 each worker pulls its rules through its own clone. + + /** What registering a state machine yields: its handle and the concurrency level the engine drew. */ + record StateMachine(long id, int concurrency) {} + + /** + * {@code ruleGroups} and {@code ruleWeights} are parallel to {@code ruleNames} ({@code null} + * weights = all equal); {@code invariantAlwaysCheck} is parallel to {@code invariantNames}. + * The engine draws the concurrency level in {@code [minConcurrency, maxConcurrency]}. + */ + StateMachine newStateMachine( + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + int stepCount); + + /** Start the next round: its group id, or {@link Abi#STATE_MACHINE_DONE} when the machine is done. */ + long stateMachineNextGroup(long stateMachineId); + + /** + * The next rule index for worker {@code workerIndex} this round, or {@link + * Abi#STATE_MACHINE_DONE} at the worker's join point. + */ + long stateMachineNextRule(long stateMachineId, long workerIndex); + + /** The rule most recently handed to worker {@code workerIndex} failed an assumption: retry the slot. */ + void stateMachineRuleRejected(long stateMachineId, long workerIndex); + + /** + * A source over an independent choice stream of the same test case ({@code + * hegel_test_case_clone}), attributed to concurrent worker {@code workerIndex}, for a worker + * thread to draw through. Release it with {@link #release()} once the worker is done with it. + */ + DataSource cloneForWorker(long workerIndex); + + /** Free the handle behind a source from {@link #cloneForWorker}. Safe once the case is aborted. */ + void release(); + + /** The engine's sampling decision for invariant {@code invariantIndex} at this join point. */ + boolean stateMachineShouldCheckInvariant(long stateMachineId, long invariantIndex); + + /** Release the machine handle. Safe once the case is aborted. */ + void stateMachineFree(long stateMachineId); + + /** Whether the case has been concluded (overrun or invalid) and its primitives now short-circuit. */ + boolean isAborted(); + + void target(double value, String label); +} diff --git a/src/main/java/dev/hegel/Database.java b/shared/src/main/java/dev/hegel/Database.java similarity index 100% rename from src/main/java/dev/hegel/Database.java rename to shared/src/main/java/dev/hegel/Database.java diff --git a/src/main/java/dev/hegel/Engine.java b/shared/src/main/java/dev/hegel/Engine.java similarity index 76% rename from src/main/java/dev/hegel/Engine.java rename to shared/src/main/java/dev/hegel/Engine.java index f8038f2..860add9 100644 --- a/src/main/java/dev/hegel/Engine.java +++ b/shared/src/main/java/dev/hegel/Engine.java @@ -1,5 +1,7 @@ package dev.hegel; +import dev.hegel.lowlevel.Libhegel; +import dev.hegel.lowlevel.LibraryLoader; import java.nio.file.Path; /** @@ -19,9 +21,11 @@ private Engine() {} static synchronized Libhegel get() { if (instance == null) { + // Each frontend jar bundles exactly one binding, registered as a LibhegelBackend + // service provider; Libhegel.load finds it. Path path = LibraryLoader.fromEnvironment().resolve(); - RealLibhegel lib = new RealLibhegel(path); - LibraryLoader.warnOnVersionMismatch(lib, LibraryLoader.targetEngineVersion(), System.err); + Libhegel lib = Libhegel.load(path); + LibraryLoader.warnOnVersionMismatch(lib.version(), LibraryLoader.targetEngineVersion(), System.err); instance = lib; } return instance; diff --git a/shared/src/main/java/dev/hegel/Failure.java b/shared/src/main/java/dev/hegel/Failure.java new file mode 100644 index 0000000..2b60326 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Failure.java @@ -0,0 +1,123 @@ +package dev.hegel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * One distinct counterexample of a failed run, as observed on the freshest failing execution the + * engine stamped for capture. + * + *

    The engine runs every failure it is about to report one final time, stamped for capture ({@link + * TestCase#isFinal()}); the runner keeps that execution's exception and every top-level draw and + * note, and that capture is what this class carries. A {@linkplain #nondeterministic() + * nondeterministic} failure — one whose test does not fail every time the same choices are + * replayed — additionally carries the engine's {@linkplain #caveat() caveat} quoting how reliably it + * reproduced, and has a reproduce blob only if the engine confirmed it. + */ +public final class Failure { + private final String origin; + private final String reproduceBlob; + private final String caveat; + private final Throwable exception; + private final Map draws; + private final List notes; + + Failure( + String origin, + String reproduceBlob, + String caveat, + Throwable exception, + Map draws, + List notes) { + this.origin = origin; + this.reproduceBlob = reproduceBlob; + this.caveat = caveat; + this.exception = exception; + this.draws = Collections.unmodifiableMap(new LinkedHashMap<>(draws)); + this.notes = Collections.unmodifiableList(new ArrayList<>(notes)); + } + + /** + * The origin the engine grouped this bug under — the exception's type and the user frame it was + * thrown from, e.g. {@code AssertionFailedError at SortTest.java:23}. Two failures with different + * origins are reported as distinct bugs. + * + * @return the origin string + */ + public String origin() { + return origin; + } + + /** + * The base64 blob that replays this counterexample, via {@link + * Settings#reproduceFailure(String)}. Only guaranteed to reproduce under the Hegel version that + * produced it. + * + * @return the reproduce blob, or empty when the engine produced none: for an unconfirmed + * {@linkplain #nondeterministic() nondeterministic} failure, and for a failure reproduced + * from a {@link Settings#reproduceFailure(String)} blob (the caller already holds it) + */ + public Optional reproduceBlob() { + return Optional.ofNullable(reproduceBlob); + } + + /** + * The engine's confirmation caveat for a nondeterministic failure: its standing under the run's + * nondeterministic handling, quoting the run's own replay evidence (for example {@code + * nondeterministic failure, confirmed: failed 7 of 20 replays at confirmation and 1 of 2 at + * report time}). Print it alongside the failure so the reader sees how reliably it reproduced. + * + * @return the caveat, or empty for a deterministic failure + */ + public Optional caveat() { + return Optional.ofNullable(caveat); + } + + /** + * Whether the test's outcome depended on something other than its generated data: the same + * choices did not fail every time the engine replayed them (hidden global state, time, an + * external RNG, thread scheduling). The engine confirms such a failure by repeated replay + * before shrinking it; {@link #caveat()} says how that went. + * + * @return {@code true} if the engine handled this failure as nondeterministic + */ + public boolean nondeterministic() { + return caveat != null; + } + + /** + * The exception the test body threw on the captured execution. + * + * @return the exception + */ + public Throwable exception() { + return exception; + } + + /** + * Every top-level draw of the captured execution, in draw order, keyed by the label passed to + * {@link TestCase#draw(Generator, String)} — numbered from its second use in the case ({@code + * x}, {@code x_2}, ...) so repeated draws are all kept — or {@code draw_N} for the N-th + * unlabelled draw. Empty when the engine reported the failure without a stamped failing + * execution to capture — an unconfirmed nondeterministic failure that never failed again after + * its discovery. + * + * @return an unmodifiable, insertion-ordered map of label to generated value + */ + public Map draws() { + return draws; + } + + /** + * Every {@link TestCase#note(String) note} recorded during the captured execution, in order. + * + * @return an unmodifiable list of notes + */ + public List notes() { + return notes; + } +} diff --git a/src/main/java/dev/hegel/Generated.java b/shared/src/main/java/dev/hegel/Generated.java similarity index 65% rename from src/main/java/dev/hegel/Generated.java rename to shared/src/main/java/dev/hegel/Generated.java index 7799125..374f4d3 100644 --- a/src/main/java/dev/hegel/Generated.java +++ b/shared/src/main/java/dev/hegel/Generated.java @@ -9,9 +9,10 @@ * Marks a method as excluded from coverage measurement. * *

    JaCoCo automatically ignores members annotated with an annotation whose name contains - * "Generated". We use it for the two genuinely unreachable defensive catch blocks: a {@code - * NoSuchAlgorithmException} for SHA-256 (mandated to exist by the JLS) and an {@code - * IllegalAccessException} after {@code setAccessible(true)} has already succeeded. + * "Generated". We use it only for genuinely unreachable defensive catch blocks: a {@code + * NoSuchAlgorithmException} for SHA-256 (mandated to exist by the JLS), the lookup of this + * library's own output-callback bridge method, and an {@code IllegalAccessException} after {@code + * setAccessible(true)} has already succeeded. */ @Retention(RetentionPolicy.CLASS) @Target({ElementType.METHOD, ElementType.TYPE}) diff --git a/src/main/java/dev/hegel/Generator.java b/shared/src/main/java/dev/hegel/Generator.java similarity index 54% rename from src/main/java/dev/hegel/Generator.java rename to shared/src/main/java/dev/hegel/Generator.java index 73a566a..1128f64 100644 --- a/src/main/java/dev/hegel/Generator.java +++ b/shared/src/main/java/dev/hegel/Generator.java @@ -1,6 +1,5 @@ package dev.hegel; -import dev.hegel.generators.BasicGenerator; import dev.hegel.generators.FilteredGenerator; import dev.hegel.generators.FlatMappedGenerator; import dev.hegel.generators.MappedGenerator; @@ -12,8 +11,8 @@ * *

    Generators are deterministic functions of the engine's choices, not sources of randomness. Use * the factory methods on {@link Generators} to construct them and the combinators here to transform - * them. Schemas and the CBOR wire format are implementation details and never surface through this - * interface. + * them. The engine's typed draw functions and span protocol are implementation details and never + * surface through this interface. * * @param the type of value produced */ @@ -22,37 +21,14 @@ public interface Generator { * Draw a value. Always draw through {@link TestCase#draw(Generator)}; this is the plumbing * beneath it, called only by the generator implementations in {@code dev.hegel.generators}. * - *

    The default delegates to {@link #asBasic()}, so a schema-describable generator need only - * implement {@code asBasic()}. A generator with no schema representation must override this. - * * @param tc the current test case * @return the generated value * @hidden internal: user code draws via {@link TestCase#draw(Generator)} */ - default T doDraw(TestCase tc) { - BasicGenerator basic = asBasic(); - if (basic == null) { - throw new IllegalStateException("a Generator must override doDraw(TestCase) or asBasic()"); - } - return basic.doDraw(tc); - } - - /** - * The schema (basic) representation of this generator, or {@code null} if it must use the - * composite draw path. A generator is basic when it can be drawn in a single engine call; a - * conditionally-basic generator (a list whose elements are basic, a {@code one_of} whose - * alternatives are all basic) returns its representation or {@code null} accordingly. - * - * @return the basic representation, or {@code null} - * @hidden internal: schemas never surface through the public API - */ - default BasicGenerator asBasic() { - return null; - } + T doDraw(TestCase tc); /** - * Transform each generated value with {@code f}. Preserves the efficient single-draw path when - * this generator is schema-describable. + * Transform each generated value with {@code f}. * * @param f the mapping function * @param the result type @@ -63,8 +39,8 @@ default Generator map(Function f) { } /** - * Keep only values satisfying {@code predicate}. Always uses the composite draw path; prefer - * constraining a generator at construction time (e.g. bounds) over filtering when possible. + * Keep only values satisfying {@code predicate}. Prefer constraining a generator at + * construction time (e.g. bounds) over filtering when possible. * * @param predicate the acceptance test * @return a filtered generator diff --git a/src/main/java/dev/hegel/Generators.java b/shared/src/main/java/dev/hegel/Generators.java similarity index 90% rename from src/main/java/dev/hegel/Generators.java rename to shared/src/main/java/dev/hegel/Generators.java index 17091a5..11ebdb8 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/shared/src/main/java/dev/hegel/Generators.java @@ -29,6 +29,7 @@ import dev.hegel.generators.UrlGenerator; import dev.hegel.generators.UuidGenerator; import dev.hegel.generators.ZoneOffsetGenerator; +import dev.hegel.lowlevel.Abi; import java.time.Duration; import java.time.LocalDate; import java.time.LocalTime; @@ -37,6 +38,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.UUID; import java.util.function.Function; /** @@ -463,8 +465,9 @@ public static Deferred deferred() { * Derive a generator for {@code type} by reflection. * *

    Supports scalar types ({@code int}, {@code long}, {@code boolean}, {@code float}, {@code - * double}, {@code String}, {@code byte[]} and their wrappers), enums, records (recursively), and - * {@code List}, {@code Set}, {@code Optional} and {@code Map} of supported element types. + * double}, {@code String}, {@code byte[]}, {@link UUID}, and their wrappers), enums, records + * (recursively), and {@code List}, {@code Set}, {@code Optional} and {@code Map} of supported + * element types. * * @param type the type to derive a generator for * @param the type @@ -504,9 +507,10 @@ public static Generator urls() { } /** - * @return a generator of syntactically valid domain names + * @return a generator of syntactically valid domain names; see {@link DomainGenerator} for + * configuration capabilities */ - public static Generator domains() { + public static DomainGenerator domains() { return new DomainGenerator(); } @@ -519,30 +523,41 @@ public static IpAddressGenerator ipAddresses() { } /** - * @return a generator of UUID strings + * Generates {@link UUID} values; see {@link UuidGenerator} for configuration capabilities. + * + * @return a UUID generator */ - public static Generator uuids() { + public static UuidGenerator uuids() { return new UuidGenerator(); } /** - * @return a generator of {@link LocalDate} values (the engine's {@code YYYY-MM-DD} output) + * Generates {@link LocalDate} values spanning years 1 to 9999 by default, shrinking toward + * 2000-01-01. Narrow the range with {@link DateGenerator#min} / {@link DateGenerator#max}. + * + * @return a date generator */ - public static Generator dates() { + public static DateGenerator dates() { return new DateGenerator(); } /** - * @return a generator of {@link LocalTime} values (the engine's {@code HH:MM:SS[.ffffff]} output) + * Generates {@link LocalTime} values across the whole day by default (at the engine's + * microsecond resolution), shrinking toward the lower bound. Narrow the range with {@link + * TimeGenerator#min} / {@link TimeGenerator#max}. + * + * @return a time generator */ - public static Generator times() { + public static TimeGenerator times() { return new TimeGenerator(); } /** - * Generates {@link java.time.LocalDateTime} values (the engine's offset-free {@code - * YYYY-MM-DDTHH:MM:SS[.ffffff]} output). Call {@link DateTimeGenerator#timezones} to produce - * offset-aware {@link java.time.OffsetDateTime} values instead. + * Generates {@link java.time.LocalDateTime} values (at the engine's microsecond resolution), + * shrinking toward 2000-01-01T00:00:00. Narrow the range with {@link DateTimeGenerator#min} / + * {@link DateTimeGenerator#max}. Call {@link DateTimeGenerator#timezones} to produce zone-aware + * {@link java.time.ZonedDateTime} values or {@link DateTimeGenerator#offsets} for offset-aware + * {@link java.time.OffsetDateTime} values instead. * * @return a datetime generator */ @@ -586,12 +601,14 @@ public static DurationGenerator durations() { } /** - * Generates strings matching a (Python-compatible) regular expression. + * Generates strings matching a (Python-compatible) regular expression. By default the entire + * string matches the pattern; use {@link RegexGenerator#fullmatch(boolean) fullmatch(false)} + * to generate strings that merely contain a match. * * @param pattern the regex pattern * @return a regex generator */ - public static Generator fromRegex(String pattern) { - return new RegexGenerator(pattern); + public static RegexGenerator fromRegex(String pattern) { + return new RegexGenerator(pattern, true); } } diff --git a/src/main/java/dev/hegel/HealthCheck.java b/shared/src/main/java/dev/hegel/HealthCheck.java similarity index 96% rename from src/main/java/dev/hegel/HealthCheck.java rename to shared/src/main/java/dev/hegel/HealthCheck.java index f13110f..437b791 100644 --- a/src/main/java/dev/hegel/HealthCheck.java +++ b/shared/src/main/java/dev/hegel/HealthCheck.java @@ -1,5 +1,7 @@ package dev.hegel; +import dev.hegel.lowlevel.Abi; + /** * Health checks the engine runs to catch tests that are misbehaving rather than buggy. Suppress one * via {@link Settings#suppressHealthCheck} when its behaviour is intentional. diff --git a/src/main/java/dev/hegel/HealthCheckFailure.java b/shared/src/main/java/dev/hegel/HealthCheckFailure.java similarity index 100% rename from src/main/java/dev/hegel/HealthCheckFailure.java rename to shared/src/main/java/dev/hegel/HealthCheckFailure.java diff --git a/shared/src/main/java/dev/hegel/Hegel.java b/shared/src/main/java/dev/hegel/Hegel.java new file mode 100644 index 0000000..86dc6a1 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Hegel.java @@ -0,0 +1,147 @@ +package dev.hegel; + +import java.util.function.Consumer; + +/** + * Programmatic entry point for running property tests. + * + *

    The preferred way to write a property test is the {@link HegelTest} annotation on a JUnit 5 + * method. Use {@code Hegel.test} only when a setting must come from a runtime value (annotation + * attributes are compile-time constants) or when running a property outside a JUnit method. The + * body comes first; settings are an optional {@link Settings} value: + * + *

    {@code
    + * import static dev.hegel.Generators.integers;
    + *
    + * // default settings
    + * Hegel.test(tc -> {
    + *   int x = tc.draw(integers());
    + *   int y = tc.draw(integers());
    + *   assertEquals(x + y, y + x);
    + * });
    + *
    + * // with settings
    + * Hegel.test(tc -> { ... }, new Settings().testCases(500).seed(42));
    + * }
    + * + *

    The test body

    + * + *

    The body runs once per generated input. Returning normally means the property held for that + * input; throwing anything — an assertion error from any framework, a plain {@code + * RuntimeException}, even a checked exception smuggled through — marks the input as a + * counterexample, which the engine then shrinks. Nothing is JUnit-specific: JUnit is optional, and + * exceptions may be constructed and thrown by hand. Two kinds of throwable are Hegel's own control + * flow and are never counterexamples: the rejection {@link TestCase#assume(boolean)} raises, and + * the overrun signal a draw raises when the engine ends a case early. A {@link HegelException} + * indicates a binding or engine error and aborts the run. + * + *

    Hegel does not inspect a thrown exception's message or fields. It uses only the exception's + * type and the first stack frame outside Hegel, the JDK, and JUnit (the failure's origin, + * exposed as {@link Failure#origin()}) to tell distinct bugs apart while shrinking. A frontend + * whose own frames sit between Hegel and the user's code lists them in {@link + * Settings#infrastructurePackages(String...)}. + * + *

    Using Hegel as a library

    + * + *

    {@link #run} is the entry point for frontends built on top of Hegel: it returns a {@link + * RunReport} instead of throwing, with the verdict, {@linkplain RunStatistics case counts}, and + * for each counterexample the exception, the labelled draws, the notes, and the reproduce blob. A + * {@link Reporter} receives the same information as callbacks while the run proceeds; pass {@link + * Reporter#silent()} to keep Hegel off {@code System.err} entirely. + * + *

    {@code
    + * RunReport report = Hegel.run(tc -> { ... }, new Settings().testCases(200), Reporter.silent());
    + * if (!report.passed()) {
    + *   for (Failure f : report.failures()) {
    + *     log(f.origin(), f.draws(), f.exception());
    + *   }
    + * }
    + * }
    + */ +public final class Hegel { + private Hegel() {} + + /** + * Run {@code body} as a property test with default settings, throwing on failure. + * + * @param body the test body, run once per generated input + * @return the passed run's report + */ + public static RunReport test(Consumer body) { + return test(body, new Settings()); + } + + /** + * Run {@code body} as a property test under {@code settings}, throwing on failure. Output goes + * to {@code System.err} through {@link Reporter#printing}. + * + * @param body the test body, run once per generated input + * @param settings the run configuration + * @return the passed run's report + */ + public static RunReport test(Consumer body, Settings settings) { + return test(body, settings, defaultReporter()); + } + + /** + * Run {@code body} as a property test under {@code settings}, reporting through {@code + * reporter} and throwing on failure. Equivalent to {@link #run(Consumer, Settings, Reporter)} + * followed by {@link RunReport#throwIfFailed()}: a failing property rethrows the body's own + * exception (or an {@link AssertionError} aggregating several distinct failures), a failed + * health check throws {@link HealthCheckFailure}, and an engine error throws {@link + * HegelException}. + * + * @param body the test body, run once per generated input + * @param settings the run configuration + * @param reporter where the run's output goes + * @return the passed run's report + */ + public static RunReport test(Consumer body, Settings settings, Reporter reporter) { + RunReport report = run(body, settings, reporter); + report.throwIfFailed(); + return report; + } + + /** + * Run {@code body} as a property test with default settings and return the report without + * throwing for a property outcome. + * + * @param body the test body, run once per generated input + * @return the run's report + */ + public static RunReport run(Consumer body) { + return run(body, new Settings()); + } + + /** + * Run {@code body} as a property test under {@code settings} and return the report without + * throwing for a property outcome. Output goes to {@code System.err} through {@link + * Reporter#printing}. + * + * @param body the test body, run once per generated input + * @param settings the run configuration + * @return the run's report + */ + public static RunReport run(Consumer body, Settings settings) { + return run(body, settings, defaultReporter()); + } + + /** + * Run {@code body} as a property test under {@code settings}, reporting through {@code + * reporter}, and return the report. A failing property, a flaky replay, and a failed health + * check are all reported, not thrown; call {@link RunReport#throwIfFailed()} to get + * {@link #test}'s behaviour. Only a binding or engine error ({@link HegelException}) throws. + * + * @param body the test body, run once per generated input + * @param settings the run configuration + * @param reporter where the run's output goes + * @return the run's report + */ + public static RunReport run(Consumer body, Settings settings, Reporter reporter) { + return Runner.run(Engine.get(), settings, body, reporter); + } + + private static Reporter defaultReporter() { + return Reporter.printing(System.err); + } +} diff --git a/src/main/java/dev/hegel/HegelException.java b/shared/src/main/java/dev/hegel/HegelException.java similarity index 71% rename from src/main/java/dev/hegel/HegelException.java rename to shared/src/main/java/dev/hegel/HegelException.java index 66ab62f..c855608 100644 --- a/src/main/java/dev/hegel/HegelException.java +++ b/shared/src/main/java/dev/hegel/HegelException.java @@ -1,5 +1,7 @@ package dev.hegel; +import dev.hegel.lowlevel.LibhegelException; + /** * Thrown for engine, configuration, and binding errors — anything that is not a property failure. * @@ -7,8 +9,10 @@ * example, so it integrates with test frameworks; this exception signals that something went wrong * with Hegel itself (a malformed generator, a missing engine library, an internal backend error). * {@link HealthCheckFailure} is the only subtype, raised when a {@link HealthCheck} aborts the run. + * It extends the binding contract's {@link LibhegelException}, which a third-party binding may + * throw directly; the runner treats both alike. */ -public sealed class HegelException extends RuntimeException permits HealthCheckFailure { +public sealed class HegelException extends LibhegelException permits HealthCheckFailure { public HegelException(String message) { super(message); } diff --git a/src/main/java/dev/hegel/HegelTest.java b/shared/src/main/java/dev/hegel/HegelTest.java similarity index 67% rename from src/main/java/dev/hegel/HegelTest.java rename to shared/src/main/java/dev/hegel/HegelTest.java index dc56290..7513f00 100644 --- a/src/main/java/dev/hegel/HegelTest.java +++ b/shared/src/main/java/dev/hegel/HegelTest.java @@ -46,11 +46,13 @@ long NO_SEED = Long.MIN_VALUE; /** - * Maximum number of valid test cases to run. + * Maximum number of valid test cases to run. The default, {@code 0}, leaves the budget to the + * engine's settings profile and the {@code HEGEL_TEST_CASES} environment variable (100 unless + * configured otherwise). * - * @return the test-case budget + * @return the test-case budget, or {@code 0} for the engine's */ - long testCases() default 100; + long testCases() default 0; /** * A fixed RNG seed for reproducibility, or {@link #NO_SEED} for none. @@ -107,21 +109,48 @@ String database() default ""; /** - * Execution mode. {@link Mode#SINGLE_TEST_CASE} runs exactly one test case with no shrinking, - * replay, or database (an exploratory probe); the default {@link Mode#TEST_RUN} runs a full test. + * The source of randomness. The default, {@link Backend#AUTO}, leaves the choice to the engine's + * settings profile: {@link Backend#URANDOM} inside Antithesis and {@link Backend#DEFAULT} otherwise. * - * @return the execution mode + * @return the randomness backend */ - Mode mode() default Mode.TEST_RUN; + Backend backend() default Backend.AUTO; /** - * Keep searching for additional distinct failures after the first and aggregate them into one - * report, instead of rethrowing the first failure directly. + * Keep searching for additional distinct failures after the first. Several distinct bugs + * aggregate into one report; a run that finds a single bug still rethrows it directly. * * @return whether to report multiple failures */ boolean reportMultipleFailures() default false; + /** + * Print a copy-pasteable {@code reproduceFailure} line for each reported failure. The default + * leaves it to the engine's settings profile and the {@code HEGEL_PRINT_BLOB} environment + * variable (on unless configured otherwise). + * + * @return whether to print reproduce blobs with failures + */ + OptBoolean printBlob() default OptBoolean.DEFAULT; + + /** + * How the run reacts when it detects a nondeterministic test. The default leaves it to the + * engine's settings profile and the {@code HEGEL_NONDETERMINISM_STRICTNESS} environment variable + * ({@link NondeterminismStrictness#QUIET} unless configured otherwise). + * + * @return the reaction to nondeterminism + */ + NondeterminismStrictness nondeterminismStrictness() default NondeterminismStrictness.DEFAULT; + + /** + * Replay a stored failure blob (printed by {@link #printBlob}) instead of running the property: + * the test body is re-run against exactly the choices the blob encodes, bypassing generation + * and shrinking. Empty (the default) runs the property normally. + * + * @return the base64 reproduce blob, or {@code ""} for none + */ + String reproduceFailure() default ""; + /** * Name for this property, used to derive a stable example-database key. Defaults to the test * method name. diff --git a/src/main/java/dev/hegel/HegelTestExtension.java b/shared/src/main/java/dev/hegel/HegelTestExtension.java similarity index 84% rename from src/main/java/dev/hegel/HegelTestExtension.java rename to shared/src/main/java/dev/hegel/HegelTestExtension.java index e05169f..9b5bd56 100644 --- a/src/main/java/dev/hegel/HegelTestExtension.java +++ b/shared/src/main/java/dev/hegel/HegelTestExtension.java @@ -1,6 +1,5 @@ package dev.hegel; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; import java.util.EnumSet; @@ -14,6 +13,7 @@ import org.junit.jupiter.api.extension.ReflectiveInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; +import org.junit.platform.commons.support.ReflectionSupport; /** * JUnit 5 extension backing {@link HegelTest}. Reports the property as a single test entry and @@ -76,22 +76,33 @@ public void interceptTestTemplateMethod( HegelTest ann = method.getAnnotation(HegelTest.class); Settings settings = settingsFrom(ann, method.getName()); Object target = invocationContext.getTarget().orElse(null); - Hegel.test(tc -> invoke(method, target, tc), settings); + Hegel.test(tc -> ReflectionSupport.invokeMethod(method, target, tc), settings); } } static Settings settingsFrom(HegelTest ann, String methodName) { String name = ann.name().isEmpty() ? methodName : ann.name(); Settings s = new Settings() - .testCases(ann.testCases()) .verbosity(ann.verbosity()) - .mode(ann.mode()) + .backend(ann.backend()) + .nondeterminismStrictness(ann.nondeterminismStrictness()) .reportMultipleFailures(ann.reportMultipleFailures()) .suppressHealthCheck(ann.suppressHealthCheck()) .name(name); + // The annotation cannot express "unset" for a primitive: 0 test cases (its default) leaves + // the engine's profile / HEGEL_TEST_CASES value in place. + if (ann.testCases() != 0) { + s = s.testCases(ann.testCases()); + } + if (ann.printBlob() != OptBoolean.DEFAULT) { + s = s.printBlob(ann.printBlob() == OptBoolean.TRUE); + } if (ann.seed() != HegelTest.NO_SEED) { s = s.seed(ann.seed()); } + if (!ann.reproduceFailure().isEmpty()) { + s = s.reproduceFailure(ann.reproduceFailure()); + } if (ann.derandomize() != OptBoolean.DEFAULT) { s = s.derandomize(ann.derandomize() == OptBoolean.TRUE); } @@ -120,21 +131,4 @@ static boolean isDefaultPhases(Phase[] phases) { static boolean isTestCaseParam(Class type) { return type == TestCase.class; } - - @Generated // thin reflective dispatch; failure propagation is verified end-to-end. - private static void invoke(Method method, Object target, TestCase tc) { - try { - method.setAccessible(true); - method.invoke(target, tc); - } catch (InvocationTargetException e) { - sneakyThrow(e.getCause()); - } catch (IllegalAccessException e) { - throw new HegelException("Cannot invoke @HegelTest method " + method.getName(), e); - } - } - - @SuppressWarnings("unchecked") - private static void sneakyThrow(Throwable t) throws E { - throw (E) t; - } } diff --git a/shared/src/main/java/dev/hegel/Invariant.java b/shared/src/main/java/dev/hegel/Invariant.java new file mode 100644 index 0000000..10be224 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Invariant.java @@ -0,0 +1,29 @@ +package dev.hegel; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method of a state-machine class as an invariant: a property {@link Stateful#run} checks + * on the machine's initial and final state and samples after rules in between (each check runs with + * probability {@code 1 / stepCount}, keeping an invariant's expected cost per test case constant as + * the step count grows). Set {@link #alwaysRun} to check it after every rule instead. The method + * must take a single {@link TestCase} parameter and assert on the machine's state. + * + *

    See {@link Stateful} for a complete example. + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Invariant { + /** + * Check this invariant after every rule instead of sampling it. Use it for invariants that must + * observe every intermediate state, including invariants that mutate state when checked. + * + * @return whether the invariant runs at every step + */ + boolean alwaysRun() default false; +} diff --git a/shared/src/main/java/dev/hegel/Label.java b/shared/src/main/java/dev/hegel/Label.java new file mode 100644 index 0000000..4e82b24 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Label.java @@ -0,0 +1,120 @@ +package dev.hegel; + +import java.nio.charset.StandardCharsets; + +/** + * Span labels for {@link TestCase#span(long, java.util.function.Supplier)} and {@link + * TestCase#startSpan(long)}. + * + *

    A label identifies the generator that opened a span, and has no meaning beyond identity: the + * engine treats two spans with the same label as coming from the same generator — candidates for + * swapping, duplicating and reordering with each other when it shrinks and mutates test cases — and + * does nothing else with it. Any 64-bit value works as long as the same generator always uses the + * same one. Derive a label from a name with {@link #of(String)}, and give a generator built from + * other generators a label {@link #combine(long...) combined} from its own and its components', so + * that a list of integers and a list of strings get different labels while every list of integers + * gets the same one. Both match the engine's {@code hegel_label_from_name} and {@code + * hegel_label_combine}, so labels minted here agree with those of every other Hegel frontend. + * + *

    The constants below are the labels Hegel's own composite generators open their spans with, + * derived from names of the form {@code dev.hegel.}. The engine labels the spans around its + * own draws from {@code hegel.} names; prefix custom names with your library's to keep clear + * of both. + */ +public final class Label { + private Label() {} + + private static final long FNV_OFFSET_BASIS = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + + /** A variable-length list. */ + public static final long LIST = of("dev.hegel.list"); + + /** One element of a list. */ + public static final long LIST_ELEMENT = of("dev.hegel.list_element"); + + /** A set. */ + public static final long SET = of("dev.hegel.set"); + + /** One element of a set. */ + public static final long SET_ELEMENT = of("dev.hegel.set_element"); + + /** A map. */ + public static final long MAP = of("dev.hegel.map"); + + /** One key/value entry of a map. */ + public static final long MAP_ENTRY = of("dev.hegel.map_entry"); + + /** A fixed-arity tuple. */ + public static final long TUPLE = of("dev.hegel.tuple"); + + /** A choice between alternative generators. */ + public static final long ONE_OF = of("dev.hegel.one_of"); + + /** An optional value. */ + public static final long OPTIONAL = of("dev.hegel.optional"); + + /** A record-like structure with a fixed set of named fields. */ + public static final long FIXED_DICT = of("dev.hegel.fixed_dict"); + + /** A draw whose generator depends on an earlier draw. */ + public static final long FLAT_MAP = of("dev.hegel.flat_map"); + + /** A draw filtered by a predicate. */ + public static final long FILTER = of("dev.hegel.filter"); + + /** A draw transformed by a function. */ + public static final long MAPPED = of("dev.hegel.mapped"); + + /** A value sampled from a fixed collection. */ + public static final long SAMPLED_FROM = of("dev.hegel.sampled_from"); + + /** One variant of an enum-like type. */ + public static final long ENUM_VARIANT = of("dev.hegel.enum_variant"); + + /** One rule invocation of a stateful test. */ + public static final long STATEFUL_RULE = of("dev.hegel.stateful_rule"); + + /** A value built imperatively from several draws ({@link Generators#composite}). */ + public static final long COMPOSITE = of("dev.hegel.composite"); + + /** + * Mint a stable label from a name: the 64-bit FNV-1a hash of its UTF-8 bytes. The same name + * always yields the same label, so a frontend can label its generators by fully-qualified name. + * + * @param name the label's name + * @return the label + */ + public static long of(String name) { + return fnv1a(FNV_OFFSET_BASIS, name.getBytes(StandardCharsets.UTF_8)); + } + + /** + * The label for a generator built from other generators: a hash of the given labels, in order. + * Pass the generator's own label (from {@link #of(String)}) first and its components' labels + * after it. Combining is order-sensitive, and combining a single label does not return it + * unchanged. + * + * @param labels the generator's own label followed by its components' + * @return the combined label + */ + public static long combine(long... labels) { + long hash = FNV_OFFSET_BASIS; + byte[] bytes = new byte[8]; + for (long label : labels) { + for (int i = 0; i < 8; i++) { + bytes[i] = (byte) (label >>> (8 * i)); + } + hash = fnv1a(hash, bytes); + } + return hash; + } + + private static long fnv1a(long hash, byte[] bytes) { + for (byte b : bytes) { + hash ^= (b & 0xffL); + hash *= FNV_PRIME; + } + return hash; + } +} diff --git a/shared/src/main/java/dev/hegel/LiveDataSource.java b/shared/src/main/java/dev/hegel/LiveDataSource.java new file mode 100644 index 0000000..9e026c2 --- /dev/null +++ b/shared/src/main/java/dev/hegel/LiveDataSource.java @@ -0,0 +1,404 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.Libhegel; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +/** + * A {@link DataSource} backed by the real engine for one in-flight test case. + * + *

    Once the engine returns {@code STOP_TEST} (or an assumption is rejected) the source is marked + * {@code aborted}: value-producing primitives short-circuit by re-raising {@link StopTest} without + * touching libhegel, and {@link #stopSpan} becomes a no-op so span-closing {@code finally} blocks + * during unwinding do not call into a case that is already being torn down. + */ +final class LiveDataSource implements DataSource { + private final Libhegel lib; + private final long tc; + private boolean aborted; + + LiveDataSource(Libhegel lib, long tc) { + this.lib = lib; + this.tc = tc; + } + + @Override + public boolean isAborted() { + return aborted; + } + + private void translate(int rc, String op) { + switch (rc) { + case Abi.OK: + return; + case Abi.E_STOP_TEST: + aborted = true; + throw new StopTest(); + case Abi.E_ASSUME: + aborted = true; + throw new AssumeRejected(); + case Abi.E_INVALID_ARG: + throw new IllegalArgumentException(nullToEmpty(lib.lastErrorMessage())); + case Abi.E_CONCURRENT_USE: + throw new HegelException("hegel_" + op + + ": the test-case handle was used from two threads at once. In a concurrent" + + " state machine, draw only through the TestCase handed to the rule and share" + + " generated values through a ConcurrentPool."); + default: + throw new HegelException( + "hegel_" + op + " failed (rc=" + rc + "): " + nullToEmpty(lib.lastErrorMessage())); + } + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + private void checkLive() { + if (aborted) { + throw new StopTest(); + } + } + + @Override + public boolean generateBoolean(double p) { + checkLive(); + boolean[] out = new boolean[1]; + translate(lib.generateBoolean(tc, p, out), "generate_boolean"); + return out[0]; + } + + @Override + public long generateInteger(long min, long max) { + checkLive(); + long[] out = new long[1]; + translate(lib.generateInteger(tc, min, max, out), "generate_integer"); + return out[0]; + } + + @Override + public double generateFloat( + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude) { + checkLive(); + double[] out = new double[1]; + translate( + lib.generateFloat( + tc, + width, + min, + max, + allowNan, + allowInfinity, + excludeMin, + excludeMax, + smallestNonzeroMagnitude, + out), + "generate_float"); + return out[0]; + } + + @Override + public byte[] generateBytes(long minSize, long maxSize) { + checkLive(); + byte[][] out = new byte[1][]; + translate(lib.generateBytes(tc, minSize, maxSize, out), "generate_bytes"); + return out[0]; + } + + @Override + public String generateString(StringGeneratorHandle generator) { + checkLive(); + String[] out = new String[1]; + translate(lib.generateString(tc, generator.handle, out), "generate_string"); + return out[0]; + } + + @Override + public LocalDate generateDate(LocalDate min, LocalDate max) { + checkLive(); + LocalDate[] out = new LocalDate[1]; + translate(lib.generateDate(tc, min, max, out), "generate_date"); + return out[0]; + } + + @Override + public LocalTime generateTime(LocalTime min, LocalTime max) { + checkLive(); + LocalTime[] out = new LocalTime[1]; + translate(lib.generateTime(tc, min, max, out), "generate_time"); + return out[0]; + } + + @Override + public LocalDateTime generateDatetime(LocalDateTime min, LocalDateTime max) { + checkLive(); + LocalDateTime[] out = new LocalDateTime[1]; + translate(lib.generateDatetime(tc, min, max, out), "generate_datetime"); + return out[0]; + } + + @Override + public UUID generateUuid(Integer version) { + checkLive(); + byte[] bytes = new byte[16]; + translate(lib.generateUuid(tc, version == null ? 0 : version, version != null, bytes), "generate_uuid"); + long msb = 0; + long lsb = 0; + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (bytes[i] & 0xffL); + lsb = (lsb << 8) | (bytes[i + 8] & 0xffL); + } + return new UUID(msb, lsb); + } + + @Override + public byte[] generateIpv4() { + checkLive(); + byte[] bytes = new byte[4]; + translate(lib.generateIpv4(tc, bytes), "generate_ipv4"); + return bytes; + } + + @Override + public byte[] generateIpv6() { + checkLive(); + byte[] bytes = new byte[16]; + translate(lib.generateIpv6(tc, bytes), "generate_ipv6"); + return bytes; + } + + @Override + public StringGeneratorHandle textGenerator( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters) { + checkLive(); + long[] out = new long[1]; + translate( + lib.stringGeneratorText( + minSize, + maxSize, + codec, + minCodepoint, + maxCodepoint, + categories, + excludeCategories, + includeCharacters, + excludeCharacters, + out), + "string_generator_text"); + return new StringGeneratorHandle(lib, out[0]); + } + + @Override + public StringGeneratorHandle regexGenerator(String pattern, boolean fullmatch, StringGeneratorHandle alphabet) { + checkLive(); + long[] out = new long[1]; + translate( + lib.stringGeneratorRegex(pattern, fullmatch, alphabet == null ? 0 : alphabet.handle, out), + "string_generator_regex"); + return new StringGeneratorHandle(lib, out[0]); + } + + @Override + public StringGeneratorHandle emailGenerator() { + checkLive(); + long[] out = new long[1]; + translate(lib.stringGeneratorEmail(out), "string_generator_email"); + return new StringGeneratorHandle(lib, out[0]); + } + + @Override + public StringGeneratorHandle urlGenerator() { + checkLive(); + long[] out = new long[1]; + translate(lib.stringGeneratorUrl(out), "string_generator_url"); + return new StringGeneratorHandle(lib, out[0]); + } + + @Override + public StringGeneratorHandle domainGenerator(long maxLength) { + checkLive(); + long[] out = new long[1]; + translate(lib.stringGeneratorDomain(maxLength, out), "string_generator_domain"); + return new StringGeneratorHandle(lib, out[0]); + } + + @Override + public boolean ownsStringGenerator(StringGeneratorHandle generator) { + return generator.lib == lib; + } + + @Override + public void startSpan(long label) { + checkLive(); + translate(lib.startSpan(tc, label), "start_span"); + } + + @Override + public void stopSpan(boolean discard) { + if (aborted) { + return; + } + translate(lib.stopSpan(tc, discard), "stop_span"); + } + + @Override + public long newCollection(long minSize, long maxSize) { + checkLive(); + long[] id = new long[1]; + translate(lib.newCollection(tc, minSize, maxSize, id), "new_collection"); + return id[0]; + } + + @Override + public boolean collectionMore(long id) { + checkLive(); + boolean[] more = new boolean[1]; + translate(lib.collectionMore(tc, id, more), "collection_more"); + return more[0]; + } + + @Override + public void collectionReject(long id, String why) { + checkLive(); + translate(lib.collectionReject(tc, id, why), "collection_reject"); + } + + @Override + public long newPool() { + checkLive(); + long[] id = new long[1]; + translate(lib.newPool(tc, id), "new_pool"); + return id[0]; + } + + @Override + public long poolAdd(long poolId) { + checkLive(); + long[] id = new long[1]; + translate(lib.poolAdd(tc, poolId, id), "pool_add"); + return id[0]; + } + + @Override + public long poolGenerate(long poolId, boolean consume) { + checkLive(); + long[] id = new long[1]; + translate(lib.poolGenerate(tc, poolId, consume, id), "pool_generate"); + return id[0]; + } + + @Override + public StateMachine newStateMachine( + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + int stepCount) { + checkLive(); + long[] id = new long[1]; + long[] concurrency = new long[1]; + translate( + lib.newStateMachine( + tc, + ruleNames, + ruleGroups, + ruleWeights, + invariantNames, + invariantAlwaysCheck, + minConcurrency, + maxConcurrency, + stepCount, + id, + concurrency), + "new_state_machine"); + return new StateMachine(id[0], (int) concurrency[0]); + } + + @Override + public long stateMachineNextGroup(long stateMachineId) { + checkLive(); + long[] group = new long[1]; + translate(lib.stateMachineNextGroup(tc, stateMachineId, group), "state_machine_next_group"); + return group[0]; + } + + @Override + public long stateMachineNextRule(long stateMachineId, long workerIndex) { + checkLive(); + long[] index = new long[1]; + translate(lib.stateMachineNextRule(tc, stateMachineId, workerIndex, index), "state_machine_next_rule"); + return index[0]; + } + + @Override + public void stateMachineRuleRejected(long stateMachineId, long workerIndex) { + checkLive(); + translate(lib.stateMachineRuleRejected(tc, stateMachineId, workerIndex), "state_machine_rule_rejected"); + } + + @Override + public DataSource cloneForWorker(long workerIndex) { + checkLive(); + // Cloning consumes a choice position, so it is a draw: on a replay of a shorter sequence + // it reports STOP_TEST like any other draw, and the case is an overrun. + long[] clone = new long[1]; + translate(lib.testCaseClone(tc, clone), "test_case_clone"); + try { + lib.testCaseSetWorker(clone[0], workerIndex); + } catch (RuntimeException e) { + lib.testCaseFree(clone[0]); + throw e; + } + return new LiveDataSource(lib, clone[0]); + } + + @Override + public void release() { + // Not gated on `aborted`: the clone handle is caller-owned and must be released exactly once. + lib.testCaseFree(tc); + } + + @Override + public boolean stateMachineShouldCheckInvariant(long stateMachineId, long invariantIndex) { + checkLive(); + boolean[] check = new boolean[1]; + translate( + lib.stateMachineShouldCheckInvariant(tc, stateMachineId, invariantIndex, check), + "state_machine_should_check_invariant"); + return check[0]; + } + + @Override + public void stateMachineFree(long stateMachineId) { + // Not gated on `aborted`: the handle outlives the case and must be released exactly once. + lib.stateMachineFree(stateMachineId); + } + + @Override + public void target(double value, String label) { + checkLive(); + translate(lib.target(tc, value, label), "target"); + } +} diff --git a/shared/src/main/java/dev/hegel/NondeterminismStrictness.java b/shared/src/main/java/dev/hegel/NondeterminismStrictness.java new file mode 100644 index 0000000..0a8723a --- /dev/null +++ b/shared/src/main/java/dev/hegel/NondeterminismStrictness.java @@ -0,0 +1,45 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; + +/** + * How a run reacts when it detects a nondeterministic test: one whose structure or outcome changes + * when the same generated choices are replayed, because it depends on something other than its + * generated data (hidden global state, time, an outside service, thread scheduling). + * + *

    Under {@link #QUIET} and {@link #WARN} the engine switches to nondeterministic handling: a + * failure is confirmed by repeated replay before it is shrunk or persisted, and reported with a + * {@linkplain Failure#caveat() caveat} quoting the run's replay evidence. An unconfirmed failure + * still fails the run, with a caveat instead of a reproduce blob. {@link #ERROR} aborts the run + * with a flaky-test error instead, for suites that use determinism as a lint. + */ +public enum NondeterminismStrictness { + /** + * Leave the choice to the engine's settings profile and the {@code HEGEL_NONDETERMINISM_STRICTNESS} + * environment variable ({@code quiet} unless configured otherwise). + */ + DEFAULT(null), + /** Switch to nondeterministic handling silently. The engine's default. */ + QUIET(Abi.NONDETERMINISM_QUIET), + /** Switch to nondeterministic handling, printing a one-line notice once per run. */ + WARN(Abi.NONDETERMINISM_WARN), + /** Abort the run with a flaky-test error ({@link HegelException}). */ + ERROR(Abi.NONDETERMINISM_ERROR); + + /** The {@code hegel_nondeterminism_strictness_t} value to send, or {@code null} to leave the profile's choice. */ + final Integer code; + + NondeterminismStrictness(Integer code) { + this.code = code; + } + + /** The strictness the engine reports back for {@code code}. */ + static NondeterminismStrictness fromCode(int code) { + for (NondeterminismStrictness s : values()) { + if (s.code != null && s.code == code) { + return s; + } + } + throw new HegelException("unknown hegel_nondeterminism_strictness_t value " + code); + } +} diff --git a/src/main/java/dev/hegel/OptBoolean.java b/shared/src/main/java/dev/hegel/OptBoolean.java similarity index 100% rename from src/main/java/dev/hegel/OptBoolean.java rename to shared/src/main/java/dev/hegel/OptBoolean.java diff --git a/src/main/java/dev/hegel/Phase.java b/shared/src/main/java/dev/hegel/Phase.java similarity index 96% rename from src/main/java/dev/hegel/Phase.java rename to shared/src/main/java/dev/hegel/Phase.java index fffb573..d68470d 100644 --- a/src/main/java/dev/hegel/Phase.java +++ b/shared/src/main/java/dev/hegel/Phase.java @@ -1,5 +1,7 @@ package dev.hegel; +import dev.hegel.lowlevel.Abi; + /** * A phase of a Hegel run. Pass a subset to {@link Settings#phases} to enable only those phases; * phases not listed are disabled. The default is all phases. diff --git a/shared/src/main/java/dev/hegel/Pool.java b/shared/src/main/java/dev/hegel/Pool.java new file mode 100644 index 0000000..31653df --- /dev/null +++ b/shared/src/main/java/dev/hegel/Pool.java @@ -0,0 +1,95 @@ +package dev.hegel; + +import java.util.HashMap; +import java.util.Map; + +/** + * A pool of previously generated values the engine can draw from and shrink over. Mostly used in + * stateful tests ({@link Stateful}), where a rule needs to act on some value an earlier rule + * produced. + * + *

    Create one per test case, populate it with {@link #add}, and draw from it through the + * generators it hands out rather than reading it directly: {@link #reusable()} yields a value + * without removing it, {@link #consuming()} removes the value it yields. Both are drawn through + * {@link TestCase#draw}, so the chosen value is recorded in the failing-test replay and the choice + * shrinks like any other draw. Drawing from an empty pool rejects the current test case (as if by + * {@code assume(false)}). + * + *

    A pool is bound to the test case it was created on and is not thread-safe: in a machine run + * with {@link Stateful.Options#maxConcurrency maxConcurrency} above 1, use a {@link + * ConcurrentPool} instead. + * + * @param the type of pooled values + */ +public final class Pool { + private final TestCase tc; + private final long poolId; + private final Map values = new HashMap<>(); + + /** + * Create a pool tracked by the current test case. + * + * @param tc the current test case + */ + public Pool(TestCase tc) { + this.tc = tc; + this.poolId = tc.newPool(); + } + + /** + * @return whether no values are in the pool + */ + public boolean isEmpty() { + return values.isEmpty(); + } + + /** + * @return the number of values currently in the pool + */ + public int size() { + return values.size(); + } + + /** + * Add a value to the pool. + * + * @param value the value to add + */ + public void add(T value) { + values.put(tc.poolAdd(poolId), value); + } + + /** + * A generator over the values in the pool that yields a value without removing it. + * + * @return the reusing generator + */ + public Generator reusable() { + return new PoolGenerator(false); + } + + /** + * A generator that consumes values from the pool: it removes the value it yields, so once + * consumed a value is never drawn again. + * + * @return the consuming generator + */ + public Generator consuming() { + return new PoolGenerator(true); + } + + private final class PoolGenerator implements Generator { + private final boolean consume; + + PoolGenerator(boolean consume) { + this.consume = consume; + } + + @Override + public T doDraw(TestCase tc) { + tc.assume(!values.isEmpty()); + long variableId = tc.poolGenerate(poolId, consume); + return consume ? values.remove(variableId) : values.get(variableId); + } + } +} diff --git a/shared/src/main/java/dev/hegel/PrintingReporter.java b/shared/src/main/java/dev/hegel/PrintingReporter.java new file mode 100644 index 0000000..53f1a70 --- /dev/null +++ b/shared/src/main/java/dev/hegel/PrintingReporter.java @@ -0,0 +1,73 @@ +package dev.hegel; + +import java.io.PrintStream; + +/** + * The default {@link Reporter}: prints the classic report to a stream, exactly as the runner did + * before reporters existed. + */ +final class PrintingReporter implements Reporter { + private final PrintStream out; + private boolean printBlob; + private boolean quiet; + private boolean multiple; + + PrintingReporter(PrintStream out) { + this.out = out; + } + + @Override + public void runStarted(Settings settings) { + printBlob = Boolean.TRUE.equals(settings.printBlob); + quiet = settings.verbosity == Verbosity.QUIET; + } + + @Override + public void engineOutput(String line) { + out.println(line); + } + + @Override + public void caseStarted(boolean finalReplay) { + // With several failures, a blank line separates one counterexample's report from the next. + if (finalReplay && multiple) { + out.println(); + } + } + + @Override + public void draw(String label, Object value, boolean finalReplay) { + if (!quiet) { + out.println(label + " = " + TestCase.repr(value) + ";"); + } + } + + @Override + public void note(String message, boolean finalReplay) { + if (!quiet) { + out.println(message); + } + } + + @Override + public void failuresFound(int count) { + multiple = count > 1; + if (multiple) { + out.println("Property-based test failed with " + count + " distinct failures."); + } + } + + @Override + public void failure(Failure failure) { + if (!quiet) { + failure.caveat().ifPresent(caveat -> out.println("note: " + caveat)); + } + if (printBlob) { + failure.reproduceBlob().ifPresent(blob -> { + out.println(); + out.println("To reproduce this failure, replay it with:"); + out.println(" @HegelTest(reproduceFailure = \"" + blob + "\")"); + }); + } + } +} diff --git a/shared/src/main/java/dev/hegel/Reporter.java b/shared/src/main/java/dev/hegel/Reporter.java new file mode 100644 index 0000000..d542c7d --- /dev/null +++ b/shared/src/main/java/dev/hegel/Reporter.java @@ -0,0 +1,130 @@ +package dev.hegel; + +import java.io.PrintStream; + +/** + * Receives everything a run has to say: engine output, per-case progress, the draws and notes of + * each minimal counterexample, and the final verdict. + * + *

    Hegel itself never writes to {@code System.out} or {@code System.err}; all output goes through + * the run's reporter. The default, {@link #printing(PrintStream) Reporter.printing(System.err)}, + * prints the classic report (each top-level draw as {@code x = 42;}, notes, and reproduce blobs). + * A frontend built on top of Hegel — another JVM language, a custom test runner — supplies its own + * implementation to route output through its logging, or {@link #silent()} to consume the {@link + * RunReport} returned by {@link Hegel#run} instead. + * + *

    Every method has a no-op default, so an implementation overrides only what it needs. Callbacks + * are invoked on the thread that drives the run, in this order: {@link #runStarted}, then for each + * case the engine runs {@link #caseStarted} / {@link #caseFinished} (with {@link #engineOutput} + * lines interleaved as the engine emits them, and the case's {@link #draw}s and {@link #note}s in + * between when the run is {@linkplain Verbosity#VERBOSE verbose}); on a failed run {@link + * #failuresFound}, then for each distinct counterexample the replay of its captured report ({@link + * #caseStarted} with {@code finalReplay = true}, the {@link #draw}s and {@link #note}s recorded on + * the failing execution the engine stamped for capture, {@link #caseFinished}) followed by {@link + * #failure}; and finally {@link #runFinished}. A reporter is per run: two concurrent runs never + * share one unless the caller passes the same instance to both. + */ +public interface Reporter { + /** + * The run is about to start under {@code settings}. + * + * @param settings the run's configuration + */ + default void runStarted(Settings settings) {} + + /** + * One line of engine output: progress at higher {@link Verbosity} levels, health-check notices, + * shrinker traces. Lines arrive without a trailing newline. + * + * @param line the line + */ + default void engineOutput(String line) {} + + /** + * The test body is about to run against a case ({@code finalReplay = false}), or the runner is + * about to replay the captured draws and notes of a counterexample it reports ({@code + * finalReplay = true}; no body runs). + * + * @param finalReplay {@code true} for the replay of a reported counterexample's capture, {@code + * false} for a live case + */ + default void caseStarted(boolean finalReplay) {} + + /** + * A top-level {@link TestCase#draw(Generator, String) draw} completed. Called when a reported + * counterexample's capture is replayed, and live on every case when the run's {@link Verbosity} + * is {@code VERBOSE} or higher; never for draws nested inside another generator. + * + * @param label the label passed to {@code draw} (numbered from its second use in a case: {@code + * x}, {@code x_2}, ...), or {@code draw_N} for the N-th unlabelled draw + * @param value the generated value + * @param finalReplay as in {@link #caseStarted} + */ + default void draw(String label, Object value, boolean finalReplay) {} + + /** + * The test body recorded a {@link TestCase#note(String) note}. Called under the same conditions + * as {@link #draw}. A note made inside a composite generator arrives after the enclosing draw. + * + * @param message the note + * @param finalReplay as in {@link #caseStarted} + */ + default void note(String message, boolean finalReplay) {} + + /** + * The test body finished against a case and the outcome was reported to the engine, or the + * replay of a reported counterexample's capture is complete (always {@link + * CaseOutcome#INTERESTING}). + * + * @param outcome how the case concluded + * @param finalReplay as in {@link #caseStarted} + */ + default void caseFinished(CaseOutcome outcome, boolean finalReplay) {} + + /** + * The run's verdict is FAILED. Called once, before the counterexamples are reported. + * + * @param count the number of distinct failures (by origin) that will be reported + */ + default void failuresFound(int count) {} + + /** + * A counterexample's captured report was replayed. + * + * @param failure the failure, carrying the captured exception, draws and notes, the engine's + * caveat for a nondeterministic failure, and the reproduce blob if there is one + */ + default void failure(Failure failure) {} + + /** + * The run is over. Called exactly once per run, last, whether it passed, failed, or errored. + * + * @param report the report {@link Hegel#run} is about to return + */ + default void runFinished(RunReport report) {} + + /** + * The classic printed report: engine output, each reported draw as {@code label = value;}, + * notes, a header when several distinct failures are reported, a {@code note:} line with the + * engine's caveat for a nondeterministic failure, and a copy-pasteable reproducer when {@link + * Settings#printBlob(boolean)} is on and the failure has a blob. Honours {@link + * Settings#verbosity}: {@link Verbosity#QUIET} prints no draws, notes or caveats at all, {@link + * Verbosity#VERBOSE} prints draws and notes for every case. + * + * @param out where to print + * @return a printing reporter + */ + static Reporter printing(PrintStream out) { + return new PrintingReporter(out); + } + + /** + * A reporter that ignores everything. Pair it with {@link Hegel#run} to consume the {@link + * RunReport} programmatically. + * + * @return a no-op reporter + */ + static Reporter silent() { + return new Reporter() {}; + } +} diff --git a/shared/src/main/java/dev/hegel/Rule.java b/shared/src/main/java/dev/hegel/Rule.java new file mode 100644 index 0000000..f04e459 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Rule.java @@ -0,0 +1,43 @@ +package dev.hegel; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method of a state-machine class as a rule: an action {@link Stateful#run} may apply to + * the machine during a stateful test. The method must take a single {@link TestCase} parameter and + * typically mutates the machine and asserts on the outcome; draw any values the action needs from + * the test case. + * + *

    See {@link Stateful} for a complete example. + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Rule { + /** + * The rule's concurrency group, for machines run with {@link Stateful.Options#maxConcurrency + * maxConcurrency} above 1. Each round the engine picks one group and hands out only that + * group's rules, so rules in the same group may run concurrently with each other and rules in + * different groups never overlap. Rules that name no group share the {@link + * Stateful#ANONYMOUS_GROUP anonymous group}. Group names are arbitrary and only appear in the + * round headers of a failure report. Groups have no observable effect on a sequential machine. + * + * @return the group name, or the empty string for the anonymous group + */ + String group() default ""; + + /** + * How often the engine should pick this rule relative to the machine's other rules: a rule of + * weight 5 is offered about five times as often as a rule of weight 1. The weight is a hint, + * not a distributional guarantee — each test case enables a random subset of rules, and a + * rule's realized frequency depends on which others are enabled alongside it. Must be finite + * and strictly positive. + * + * @return the rule's selection weight + */ + double weight() default 1.0; +} diff --git a/shared/src/main/java/dev/hegel/RunReport.java b/shared/src/main/java/dev/hegel/RunReport.java new file mode 100644 index 0000000..8f53985 --- /dev/null +++ b/shared/src/main/java/dev/hegel/RunReport.java @@ -0,0 +1,135 @@ +package dev.hegel; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * The outcome of one property-test run: verdict, case statistics, and the counterexamples found. + * + *

    Returned by {@link Hegel#run} (which never throws for a property outcome) and by {@link + * Hegel#test} (which first calls {@link #throwIfFailed()}). + */ +public final class RunReport { + private final RunStatus status; + private final RunStatistics statistics; + private final String error; + private final List failures; + + RunReport(RunStatus status, RunStatistics statistics, String error, List failures) { + this.status = status; + this.statistics = statistics; + this.error = error; + this.failures = Collections.unmodifiableList(failures); + } + + /** + * The run's verdict. + * + * @return the status + */ + public RunStatus status() { + return status; + } + + /** + * Whether the property held for every generated input. + * + * @return {@code true} iff {@link #status()} is {@link RunStatus#PASSED} + */ + public boolean passed() { + return status == RunStatus.PASSED; + } + + /** + * How many cases the run executed, by outcome. + * + * @return the statistics + */ + public RunStatistics statistics() { + return statistics; + } + + /** + * The engine's message for a run that produced no verdict (a failed health check, an engine + * error). + * + * @return the message, or empty unless {@link #status()} is {@link RunStatus#ERROR} + */ + public Optional error() { + return Optional.ofNullable(error); + } + + /** + * Whether the run was aborted by a health check (for example, the generators rejected almost + * every input). Suppress it with {@link Settings#suppressHealthCheck(HealthCheck...)} if the + * behaviour is intentional. + * + * @return {@code true} if {@link #error()} is a health-check failure + */ + public boolean healthCheckFailed() { + return error != null && error.startsWith("FailedHealthCheck"); + } + + /** + * The distinct counterexamples the run found, in the order the engine reported them. Empty + * unless {@link #status()} is {@link RunStatus#FAILED}; more than one only with {@link + * Settings#reportMultipleFailures(boolean)}. + * + * @return an unmodifiable list of failures + */ + public List failures() { + return failures; + } + + /** + * Turn a non-passing report into the exception {@link Hegel#test} throws: nothing for a passed + * run; {@link HealthCheckFailure} or {@link HegelException} (with the engine's message) for an + * errored run; for a failed run, the single failure's own exception rethrown as-is, or an + * {@link AssertionError} aggregating several distinct failures (carrying the originals as + * suppressed exceptions). + */ + public void throwIfFailed() { + if (status == RunStatus.PASSED) { + return; + } + if (status == RunStatus.ERROR) { + if (healthCheckFailed()) { + throw new HealthCheckFailure(error); + } + throw new HegelException(error); + } + if (failures.size() == 1) { + throw unchecked(failures.get(0).exception()); + } + StringBuilder sb = new StringBuilder(); + sb.append("Hegel found ").append(failures.size()).append(" distinct failing examples:"); + for (Failure failure : failures) { + sb.append("\n\n").append(describe(failure.exception())); + } + AssertionError aggregate = new AssertionError(sb.toString()); + for (Failure failure : failures) { + aggregate.addSuppressed(failure.exception()); + } + throw aggregate; + } + + /** + * Rethrow {@code t} with its original type: an unchecked exception is returned for the caller to + * throw, anything else (an {@link Error}, or a checked exception a frontend such as Kotlin or + * Clojure can raise from the body) is thrown here as-is through generic erasure. The body's + * exception is the user's own; wrapping it would hide its type and stack trace. + */ + @SuppressWarnings("unchecked") + private static RuntimeException unchecked(Throwable t) throws T { + if (t instanceof RuntimeException) { + return (RuntimeException) t; + } + throw (T) t; + } + + private static String describe(Throwable e) { + String msg = e.getMessage(); + return msg == null ? e.getClass().getName() : e.getClass().getName() + ": " + msg; + } +} diff --git a/shared/src/main/java/dev/hegel/RunStatistics.java b/shared/src/main/java/dev/hegel/RunStatistics.java new file mode 100644 index 0000000..bfca00a --- /dev/null +++ b/shared/src/main/java/dev/hegel/RunStatistics.java @@ -0,0 +1,90 @@ +package dev.hegel; + +/** + * How many test cases a run executed, by outcome. + * + *

    The counts cover every case the test body ran against — generation, targeting, and shrinking + * alike, plus the final replays of any counterexamples — so on a failing run they exceed the + * {@link Settings#testCases(long) test-case budget}, which bounds valid generated cases only. + */ +public final class RunStatistics { + private final long valid; + private final long invalid; + private final long overrun; + private final long interesting; + + RunStatistics(long valid, long invalid, long overrun, long interesting) { + this.valid = valid; + this.invalid = invalid; + this.overrun = overrun; + this.interesting = interesting; + } + + /** + * Every case executed, whatever its outcome. + * + * @return the total number of cases + */ + public long total() { + return valid + invalid + overrun + interesting; + } + + /** + * Cases for which the property held. + * + * @return the number of {@link CaseOutcome#VALID} cases + */ + public long valid() { + return valid; + } + + /** + * Cases rejected by {@link TestCase#assume(boolean)}. + * + * @return the number of {@link CaseOutcome#INVALID} cases + */ + public long invalid() { + return invalid; + } + + /** + * Cases that drew more data than the engine allowed. + * + * @return the number of {@link CaseOutcome#OVERRUN} cases + */ + public long overrun() { + return overrun; + } + + /** + * Cases for which the property failed. + * + * @return the number of {@link CaseOutcome#INTERESTING} cases + */ + public long interesting() { + return interesting; + } + + @Override + public String toString() { + return "RunStatistics{total=" + total() + ", valid=" + valid + ", invalid=" + invalid + ", overrun=" + overrun + + ", interesting=" + interesting + "}"; + } + + /** Mutable accumulator the runner counts into; one per run. */ + static final class Counter { + private final long[] counts = new long[CaseOutcome.values().length]; + + void record(CaseOutcome outcome) { + counts[outcome.ordinal()]++; + } + + RunStatistics snapshot() { + return new RunStatistics( + counts[CaseOutcome.VALID.ordinal()], + counts[CaseOutcome.INVALID.ordinal()], + counts[CaseOutcome.OVERRUN.ordinal()], + counts[CaseOutcome.INTERESTING.ordinal()]); + } + } +} diff --git a/shared/src/main/java/dev/hegel/RunStatus.java b/shared/src/main/java/dev/hegel/RunStatus.java new file mode 100644 index 0000000..33c0c32 --- /dev/null +++ b/shared/src/main/java/dev/hegel/RunStatus.java @@ -0,0 +1,14 @@ +package dev.hegel; + +/** The aggregate verdict of a run, as carried by {@link RunReport#status()}. */ +public enum RunStatus { + /** The property held for every generated input. */ + PASSED, + /** The property failed; see {@link RunReport#failures()}. */ + FAILED, + /** + * The run produced no verdict on the property: a failed health check, or an engine error. See + * {@link RunReport#error()}. + */ + ERROR +} diff --git a/shared/src/main/java/dev/hegel/Runner.java b/shared/src/main/java/dev/hegel/Runner.java new file mode 100644 index 0000000..4dba0f8 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Runner.java @@ -0,0 +1,348 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.Libhegel; +import dev.hegel.lowlevel.LibhegelException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Drives a single property test: builds the settings handle, pumps the engine's run loop, and turns + * the aggregated result into a {@link RunReport}. + * + *

    The engine owns the whole run — generation, shrinking, the replays that confirm a failure, and + * the final replay of every failure it is about to report — so the loop only pumps cases and keeps + * the report material as they run. The engine stamps the executions a failure report can be built + * from ({@code hegel_test_case_should_capture}); a stamped case records its draws and notes, and + * every interesting case's exception is kept per origin, a stamped capture winning over an unstamped + * one and the newest winning at equal rank. Once the loop drains, each reported failure is built + * from its origin's capture: the draws and notes are replayed to the reporter, the exception is the + * body's own, and the engine's caveat and reproduce blob are attached. A {@link + * Settings#reproduceFailure} run is the same loop over {@code hegel_run_start_blob}. Run-level errors + * (a failed health check, a nondeterminism abort under {@link NondeterminismStrictness#ERROR}, an + * engine panic) surface with the engine's own message. + * + *

    Everything the run has to say goes through its {@link Reporter}; the runner itself never + * prints. Binding and engine errors ({@link HegelException}) are thrown, not reported: they are bugs + * in the plumbing, not verdicts on the property. + */ +final class Runner { + private Runner() {} + + /** + * Package prefixes treated as Hegel/JDK/test-framework infrastructure: {@link #originOf} skips + * frames in these to find the user frame that owns a failure (used as the shrink-dedup origin). + * {@link Settings#infrastructurePackages(String...)} adds a frontend's own. + */ + private static final String[] INFRA_PREFIXES = { + "dev.hegel.", "org.junit.", "org.opentest4j.", "jdk.", "java.", "sun.", "com.sun." + }; + + /** Message for a {@link Settings#reproduceFailure} blob none of whose replays failed. */ + static final String STALE_BLOB = "reproduceFailure: the supplied failure blob did not reproduce a failure." + + " The failure may have been fixed — or, for a nondeterministic blob, it may not have" + + " recurred within the replay budget. Re-run to retry, or remove the blob once the failure" + + " is fixed."; + + /** + * The body's outcome against one case: the case (holding its draws and notes when the engine + * stamped it for capture), the exception that made it interesting ({@code null} otherwise), and + * the origin the case was marked complete with. + */ + private static final class CaseRun { + final TestCase testCase; + final Throwable interesting; + final String origin; + + CaseRun(TestCase testCase, Throwable interesting, String origin) { + this.testCase = testCase; + this.interesting = interesting; + this.origin = origin; + } + + /** A stamped capture outranks an unstamped one, whose only material is the exception. */ + int rank() { + return testCase.isFinal() ? 1 : 0; + } + } + + static RunReport run(Libhegel lib, Settings settings, Consumer body, Reporter reporter) { + RunStatistics.Counter counts = new RunStatistics.Counter(); + RunReport report; + long s = newSettings(lib); + try { + applySettings(lib, s, settings); + // The engine resolved whatever the caller left unset (its profile, hegel.toml, the + // HEGEL_* variables); the run and its reporters see the effective configuration. + Settings effective = settings.resolved( + lib.settingsGetTestCases(s), + lib.settingsGetPrintBlob(s), + NondeterminismStrictness.fromCode(lib.settingsGetNondeterminismStrictness(s))); + reporter.runStarted(effective); + long run = effective.reproduceFailure == null + ? lib.runStart(s, reporter::engineOutput) + : lib.runStartBlob(s, effective.reproduceFailure, reporter::engineOutput); + try { + report = drive(lib, run, effective, body, reporter, counts); + } finally { + lib.runFree(run); + } + } finally { + lib.settingsFree(s); + } + reporter.runFinished(report); + return report; + } + + /** + * Construct the engine's settings handle. The engine resolves its settings profile and the + * {@code HEGEL_*} environment variables here, so this is the one infrastructure call that can + * fail on user input: a malformed {@code hegel.toml} or variable surfaces as an {@link + * IllegalArgumentException} carrying the engine's diagnostic. + */ + private static long newSettings(Libhegel lib) { + long[] out = new long[1]; + int rc = lib.settingsNew(out); + if (rc == Abi.E_INVALID_ARG) { + throw new IllegalArgumentException(nullToEmpty(lib.lastErrorMessage())); + } + if (rc != Abi.OK) { + throw new HegelException( + "hegel_settings_new failed (rc=" + rc + "): " + nullToEmpty(lib.lastErrorMessage())); + } + return out[0]; + } + + /** + * Pump the run to completion, keeping every interesting case's capture per origin, and + * translate its verdict. + */ + private static RunReport drive( + Libhegel lib, + long run, + Settings settings, + Consumer body, + Reporter reporter, + RunStatistics.Counter counts) { + Map captures = new HashMap<>(); + while (true) { + long tc = lib.nextTestCase(run); + if (isNull(tc)) { + break; + } + CaseRun caseRun = driveOneCase(lib, tc, body, settings, reporter, counts); + if (caseRun.interesting != null) { + CaseRun stored = captures.get(caseRun.origin); + if (stored == null || caseRun.rank() >= stored.rank()) { + captures.put(caseRun.origin, caseRun); + } + } + } + long result = lib.runResult(run); + try { + return finish(lib, result, settings, reporter, counts, captures); + } finally { + lib.runResultFree(result); + } + } + + /** Translate a drained run's result into a report. */ + private static RunReport finish( + Libhegel lib, + long result, + Settings settings, + Reporter reporter, + RunStatistics.Counter counts, + Map captures) { + boolean replayingBlob = settings.reproduceFailure != null; + switch (lib.runResultStatus(result)) { + case Abi.RUN_STATUS_PASSED: + if (replayingBlob) { + throw new HegelException(STALE_BLOB); + } + return new RunReport(RunStatus.PASSED, counts.snapshot(), null, List.of()); + case Abi.RUN_STATUS_ERROR: + // The run produced no verdict on the property: a failed health check, a + // nondeterminism abort under ERROR strictness, an engine panic — or, on a blob + // replay, a blob the engine could not decode. + String error = nullToEmpty(lib.runResultError(result)); + if (replayingBlob) { + throw new HegelException("reproduceFailure: the supplied blob is not valid: " + error); + } + return new RunReport(RunStatus.ERROR, counts.snapshot(), error, List.of()); + default: + return reportFailures(lib, result, reporter, counts, captures); + } + } + + /** Build every distinct failure from its origin's capture and hand it to the reporter. */ + private static RunReport reportFailures( + Libhegel lib, long result, Reporter reporter, RunStatistics.Counter counts, Map captures) { + long count = lib.runResultFailureCount(result); + reporter.failuresFound((int) count); + List failures = new ArrayList<>(); + for (int i = 0; i < count; i++) { + String origin = lib.failureOrigin(result, i); + CaseRun capture = captures.remove(origin); + if (capture == null) { + throw new HegelException( + "internal error: failure " + i + " (" + origin + ") has no captured failing execution"); + } + reporter.caseStarted(true); + capture.testCase.replayTo(reporter); + reporter.caseFinished(CaseOutcome.INTERESTING, true); + Failure failure = new Failure( + origin, + lib.failureBlob(result, i), + lib.failureCaveat(result, i), + capture.interesting, + capture.testCase.draws(), + capture.testCase.notes()); + reporter.failure(failure); + failures.add(failure); + } + return new RunReport(RunStatus.FAILED, counts.snapshot(), null, failures); + } + + /** + * Run the body once against {@code tc}, report the outcome to the engine and the reporter, and + * free the handle. The returned {@link CaseRun} carries the exception that made the case + * interesting ({@code null} for any other outcome) and the case itself, whose draws and notes + * were recorded if the engine stamped it for capture. + */ + private static CaseRun driveOneCase( + Libhegel lib, + long tc, + Consumer body, + Settings settings, + Reporter reporter, + RunStatistics.Counter counts) { + try { + boolean verbose = settings.verbosity.code >= Verbosity.VERBOSE.code; + TestCase testCase = + new TestCase(new LiveDataSource(lib, tc), lib.testCaseShouldCapture(tc), verbose, reporter); + reporter.caseStarted(false); + CaseOutcome outcome; + String origin = null; + Throwable interesting = null; + try { + body.accept(testCase); + outcome = CaseOutcome.VALID; + } catch (AssumeRejected e) { + outcome = CaseOutcome.INVALID; + } catch (StopTest e) { + outcome = CaseOutcome.OVERRUN; + } catch (LibhegelException e) { + // A binding/engine error, not a property failure: abort the whole run. + throw e; + } catch (Throwable e) { + outcome = CaseOutcome.INTERESTING; + origin = originOf(e, settings.infrastructurePackages); + interesting = e; + } + int rc = lib.markComplete(tc, outcome.status, origin); + if (rc != Abi.OK) { + throw new HegelException( + "hegel_mark_complete failed (rc=" + rc + "): " + nullToEmpty(lib.lastErrorMessage())); + } + counts.record(outcome); + reporter.caseFinished(outcome, false); + return new CaseRun(testCase, interesting, origin); + } finally { + // The handle is caller-owned. On the error paths above the case may be incomplete; + // the run still holds its own reference and completes it when freed. + lib.testCaseFree(tc); + } + } + + /** + * Send the caller's explicit settings to the handle. Anything left unset keeps the value the + * engine resolved from its profile (a {@code hegel.toml}, or the shipped {@code ci}/{@code + * workload} profiles it selects from the environment) and the {@code HEGEL_*} variables, which + * is what makes explicit Java settings win over both. + */ + static void applySettings(Libhegel lib, long s, Settings st) { + if (st.testCases != null) { + lib.settingsTestCases(s, st.testCases); + } + lib.settingsVerbosity(s, st.verbosity.code); + if (st.hasSeed) { + lib.settingsSeed(s, st.seed, true); + } + if (st.derandomize != null) { + lib.settingsDerandomize(s, st.derandomize); + } + lib.settingsReportMultipleFailures(s, st.reportMultipleFailures); + if (st.printBlob != null) { + lib.settingsPrintBlob(s, st.printBlob); + } + if (st.nondeterminismStrictness.code != null) { + lib.settingsNondeterminismStrictness(s, st.nondeterminismStrictness.code); + } + if (st.backend.code != null) { + lib.settingsBackend(s, st.backend.code); + } + if (st.suppressMask != 0) { + lib.settingsSuppressHealthCheck(s, st.suppressMask); + } + if (st.phasesMask != null) { + lib.settingsPhases(s, st.phasesMask); + } + + switch (st.database.kind) { + case DISABLED: + lib.settingsDatabase(s, ""); + break; + case PATH: + lib.settingsDatabase(s, st.database.path); + break; + default: + // Unset: the engine's profile decides (the ci and workload profiles disable it). + break; + } + // The key is sent whenever there is one, even with the database off: the engine also derives + // the derandomized seed from it, so gating this on dbEnabled would make every named test in + // CI (where the database is disabled) derandomize off the same fallback key. + if (st.name != null) { + lib.settingsDatabaseKey(s, st.name); + } + } + + /** + * The shrink-dedup origin of a failure: the exception's simple type name and the first stack + * frame outside Hegel, the JDK, the test framework, and {@code infrastructurePackages}. + */ + static String originOf(Throwable e, List infrastructurePackages) { + for (StackTraceElement f : e.getStackTrace()) { + if (isUserFrame(f.getClassName(), infrastructurePackages)) { + return e.getClass().getSimpleName() + " at " + f.getFileName() + ":" + f.getLineNumber(); + } + } + return e.getClass().getName(); + } + + private static boolean isUserFrame(String className, List infrastructurePackages) { + for (String prefix : INFRA_PREFIXES) { + if (className.startsWith(prefix)) { + return false; + } + } + for (String prefix : infrastructurePackages) { + if (className.startsWith(prefix)) { + return false; + } + } + return true; + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + static boolean isNull(long handle) { + return handle == 0; + } +} diff --git a/shared/src/main/java/dev/hegel/Settings.java b/shared/src/main/java/dev/hegel/Settings.java new file mode 100644 index 0000000..4c908e7 --- /dev/null +++ b/shared/src/main/java/dev/hegel/Settings.java @@ -0,0 +1,304 @@ +package dev.hegel; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Immutable configuration for a Hegel run, built with a fluent builder. + * + *

    Start from {@code new Settings()}, adjust with the fluent methods, and pass the result to + * {@link Hegel#test(java.util.function.Consumer, Settings)}: + * + *

    {@code
    + * Hegel.test(tc -> { ... }, new Settings().testCases(500).seed(42));
    + * }
    + * + *

    Whatever is not set here is resolved by the engine: from its settings profile — a {@code + * hegel.toml} in the working directory or one of its parents (or the file named by {@code + * HEGEL_CONFIG}), with {@code HEGEL_DEFAULT_PROFILE} selecting the profile in effect — and from the + * {@code HEGEL_TEST_CASES}, {@code HEGEL_DATABASE}, {@code HEGEL_SEED}, {@code HEGEL_DERANDOMIZE}, + * {@code HEGEL_PRINT_BLOB} and {@code HEGEL_NONDETERMINISM_STRICTNESS} environment variables, which + * win over the profile. Settings given here win over both. The shipped profiles run 100 test cases, and in CI (detected via {@code CI}/{@code + * GITHUB_ACTIONS}/... environment variables) the {@code ci} profile applies: runs are deterministic + * ({@code derandomize}) and the example database is disabled. + */ +public final class Settings { + final Long testCases; // null = leave the engine's profile / HEGEL_TEST_CASES value + final boolean hasSeed; + final long seed; + final Boolean derandomize; + final Database database; + final int suppressMask; + final Integer phasesMask; // null = leave the engine default (all phases) + final Verbosity verbosity; + final Backend backend; + // Default false: a single, directly-rethrown failure is far friendlier to debuggers and stack + // traces than an aggregated report — and that matters more in Java than elsewhere. + final boolean reportMultipleFailures; + final Boolean printBlob; // null = leave the engine's profile / HEGEL_PRINT_BLOB value + final NondeterminismStrictness nondeterminismStrictness; + final String reproduceFailure; // null = run normally instead of replaying a blob + final String name; + final List infrastructurePackages; + + /** + * Create settings that leave everything to the engine's profile and environment (100 test + * cases and all phases in the shipped profiles) except normal verbosity and single-failure + * reporting. + */ + public Settings() { + this(new Builder()); + } + + private Settings(Builder b) { + this.testCases = b.testCases; + this.hasSeed = b.hasSeed; + this.seed = b.seed; + this.derandomize = b.derandomize; + this.database = b.database; + this.suppressMask = b.suppressMask; + this.phasesMask = b.phasesMask; + this.verbosity = b.verbosity; + this.backend = b.backend; + this.reportMultipleFailures = b.reportMultipleFailures; + this.printBlob = b.printBlob; + this.nondeterminismStrictness = b.nondeterminismStrictness; + this.reproduceFailure = b.reproduceFailure; + this.name = b.name; + this.infrastructurePackages = b.infrastructurePackages; + } + + /** Return a copy of these settings with {@code mutator} applied to the changed fields. */ + private Settings with(Consumer mutator) { + Builder b = new Builder(); + b.testCases = testCases; + b.hasSeed = hasSeed; + b.seed = seed; + b.derandomize = derandomize; + b.database = database; + b.suppressMask = suppressMask; + b.phasesMask = phasesMask; + b.verbosity = verbosity; + b.backend = backend; + b.reportMultipleFailures = reportMultipleFailures; + b.printBlob = printBlob; + b.nondeterminismStrictness = nondeterminismStrictness; + b.reproduceFailure = reproduceFailure; + b.name = name; + b.infrastructurePackages = infrastructurePackages; + mutator.accept(b); + return new Settings(b); + } + + /** Mutable field holder used only to construct and copy {@link Settings}; holds the defaults. */ + private static final class Builder { + Long testCases = null; + boolean hasSeed = false; + long seed = 0L; + Boolean derandomize = null; + Database database = Database.unset(); + int suppressMask = 0; + Integer phasesMask = null; + Verbosity verbosity = Verbosity.NORMAL; + Backend backend = Backend.AUTO; + boolean reportMultipleFailures = false; + Boolean printBlob = null; + NondeterminismStrictness nondeterminismStrictness = NondeterminismStrictness.DEFAULT; + String reproduceFailure = null; + String name = null; + List infrastructurePackages = List.of(); + } + + /** + * Set the maximum number of valid test cases to run. Unset, the engine's profile or the {@code + * HEGEL_TEST_CASES} environment variable decides (100 in the shipped profiles). + * + * @param n the test-case budget + * @return a new settings instance + */ + public Settings testCases(long n) { + if (n <= 0) { + throw new IllegalArgumentException("testCases must be positive, got " + n); + } + return with(b -> b.testCases = n); + } + + /** + * Pin the RNG seed for a reproducible run. + * + * @param seed the seed + * @return a new settings instance + */ + public Settings seed(long seed) { + return with(b -> { + b.hasSeed = true; + b.seed = seed; + }); + } + + /** + * Force deterministic (or non-deterministic) input selection regardless of the engine's profile + * (deterministic in CI) and the {@code HEGEL_DERANDOMIZE} environment variable. + * + * @param derandomize whether to derive the seed deterministically + * @return a new settings instance + */ + public Settings derandomize(boolean derandomize) { + return with(b -> b.derandomize = derandomize); + } + + /** + * Configure the example database. Pass {@link Database#unset()} to keep the engine default, + * {@link Database#disabled()} to turn it off entirely, or {@link Database#path(String)} to use a + * specific directory. + * + * @param database the database setting + * @return a new settings instance + */ + public Settings database(Database database) { + return with(b -> b.database = database); + } + + /** + * Suppress the listed health checks. + * + * @param checks the checks to disable + * @return a new settings instance + */ + public Settings suppressHealthCheck(HealthCheck... checks) { + return with(b -> { + for (HealthCheck c : checks) { + b.suppressMask |= c.bit; + } + }); + } + + /** + * Enable only the listed phases; phases not listed are disabled. The default is all phases. With + * an empty argument list the run does nothing. + * + * @param phases the phases to enable + * @return a new settings instance + */ + public Settings phases(Phase... phases) { + return with(b -> { + b.phasesMask = 0; + for (Phase p : phases) { + b.phasesMask |= p.bit; + } + }); + } + + /** + * Set engine output verbosity. + * + * @param verbosity the verbosity level + * @return a new settings instance + */ + public Settings verbosity(Verbosity verbosity) { + return with(b -> b.verbosity = verbosity); + } + + /** + * Select the source of randomness (default {@link Backend#AUTO}: the engine's settings profile + * decides, {@link Backend#URANDOM} inside Antithesis and {@link Backend#DEFAULT} otherwise). + * + * @param backend the randomness backend + * @return a new settings instance + */ + public Settings backend(Backend backend) { + return with(b -> b.backend = backend); + } + + /** + * Control whether the run keeps searching for additional distinct failures after the first. + * Defaults to {@code false}: the run stops at the first failing example. When enabled, several + * distinct bugs aggregate into one report (carrying the originals as suppressed exceptions); a + * run that finds a single bug still rethrows it directly, preserving its type and stack trace. + * + * @param yes whether to report multiple failures + * @return a new settings instance + */ + public Settings reportMultipleFailures(boolean yes) { + return with(b -> b.reportMultipleFailures = yes); + } + + /** + * Print a copy-pasteable {@code reproduceFailure} line for each reported failure. The reproduce + * blob is always attached to a failure; this only controls whether it is printed. Unset, the + * engine's profile or the {@code HEGEL_PRINT_BLOB} environment variable decides (on in the + * shipped profiles). + * + * @param yes whether to print reproduce blobs with failures + * @return a new settings instance + */ + public Settings printBlob(boolean yes) { + return with(b -> b.printBlob = yes); + } + + /** + * How the run reacts when it detects a nondeterministic test. Unset ({@link + * NondeterminismStrictness#DEFAULT}), the engine's profile or the {@code + * HEGEL_NONDETERMINISM_STRICTNESS} environment variable decides ({@link + * NondeterminismStrictness#QUIET} in the shipped profiles). + * + * @param strictness the reaction + * @return a new settings instance + */ + public Settings nondeterminismStrictness(NondeterminismStrictness strictness) { + return with(b -> b.nondeterminismStrictness = strictness); + } + + /** + * Replay a single stored failure instead of running the property: the blob (from {@link + * #printBlob(boolean)} output) is decoded and the test body re-run against the choices it + * encodes, bypassing generation and shrinking. The engine replays until a replay fails, under a + * bounded budget: a deterministic blob a few times, a nondeterministic one's recorded runs until + * one of them fails again. The run fails with the reproduced failure, or reports a stale blob if + * none of the replays fails. A blob is only guaranteed to reproduce under the Hegel version that + * produced it. + * + * @param blob the base64 reproduce blob + * @return a new settings instance + */ + public Settings reproduceFailure(String blob) { + return with(b -> b.reproduceFailure = blob); + } + + /** + * Name this property (used to derive a stable database key). + * + * @param name the test name + * @return a new settings instance + */ + public Settings name(String name) { + return with(b -> b.name = name); + } + + /** + * Class-name prefixes to treat as infrastructure when locating the user frame a failure was + * thrown from. Hegel tells distinct bugs apart by the exception's type and the first stack + * frame outside Hegel, the JDK, and JUnit; a frontend whose own frames sit between Hegel and + * the user's code (for example {@code "clojure.lang."} and {@code "clojure.core"}) lists them + * here so its frames are skipped too. Replaces any previously set prefixes. + * + * @param prefixes class-name prefixes to skip + * @return a new settings instance + */ + public Settings infrastructurePackages(String... prefixes) { + List copy = List.of(prefixes); + return with(b -> b.infrastructurePackages = copy); + } + + /** + * These settings with the values the engine resolved for what was left unset, so a run and its + * reporters see the effective configuration. + */ + Settings resolved(long testCases, boolean printBlob, NondeterminismStrictness strictness) { + return with(b -> { + b.testCases = testCases; + b.printBlob = printBlob; + b.nondeterminismStrictness = strictness; + }); + } +} diff --git a/shared/src/main/java/dev/hegel/Stateful.java b/shared/src/main/java/dev/hegel/Stateful.java new file mode 100644 index 0000000..fbe095c --- /dev/null +++ b/shared/src/main/java/dev/hegel/Stateful.java @@ -0,0 +1,763 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.LibhegelException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Stateful (model-based) testing: the engine picks which action runs next and this driver applies + * it, so failing action sequences shrink like any other generated value. + * + *

    Define a state-machine class whose {@link Rule @Rule} methods are the actions and whose + * {@link Invariant @Invariant} methods are properties checked before the first action and after + * each successful one. Both take a single {@link TestCase} parameter. Then run it inside a property + * test: + * + *

    {@code
    + * class IntegerStack {
    + *   private final Deque stack = new ArrayDeque<>();
    + *
    + *   @Rule
    + *   void push(TestCase tc) {
    + *     stack.push(tc.draw(integers()));
    + *   }
    + *
    + *   @Rule
    + *   void pop(TestCase tc) {
    + *     tc.assume(!stack.isEmpty());
    + *     stack.pop();
    + *   }
    + *
    + *   @Invariant
    + *   void sizeIsNonNegative(TestCase tc) {
    + *     assertTrue(stack.size() >= 0);
    + *   }
    + * }
    + *
    + * @HegelTest
    + * void stackBehaves(TestCase tc) {
    + *   Stateful.run(new IntegerStack(), tc);
    + * }
    + * }
    + * + *

    Each test case enables a random subset of rules (swarm testing) and runs an engine-chosen + * number of steps. A rule that fails an assumption is skipped without counting as a step. Invariants + * are checked in full on the machine's initial and final state and sampled in between: after any + * given rule each invariant runs with probability {@code 1 / stepCount}, so its expected cost per + * test case stays constant as the step count grows. The step count is the target number of rules + * per test case, {@link #DEFAULT_STEP_COUNT} unless {@link Options#stepCount} sets another. Mark + * an invariant {@code @Invariant(alwaysRun = true)} to check it after every rule instead. Give a + * rule that should run more often than the others a {@link Rule#weight() weight}: {@code + * @Rule(weight = 5)} is offered about five times as often as a plain {@code @Rule}. Use a {@link + * Pool} to act on previously generated values. + * + *

    Concurrent machines

    + * + *

    To look for concurrency bugs, run the machine with {@link Options#maxConcurrency} above 1: + * + *

    {@code
    + * class Counter {
    + *   private final Map store = new ConcurrentHashMap<>();
    + *   private final AtomicInteger increments = new AtomicInteger();
    + *   private final ConcurrentPool keys;
    + *
    + *   Counter(TestCase tc) {
    + *     keys = new ConcurrentPool<>(tc);
    + *   }
    + *
    + *   @Rule(group = "ops")
    + *   void register(TestCase tc) {
    + *     String key = tc.draw(text().minSize(1).maxSize(3));
    + *     store.putIfAbsent(key, 0);
    + *     keys.add(tc, key);
    + *   }
    + *
    + *   @Rule(group = "ops", weight = 3)
    + *   void increment(TestCase tc) {
    + *     String key = tc.draw(keys.reusable());
    + *     store.put(key, store.get(key) + 1); // racy: a lost update
    + *     increments.incrementAndGet();
    + *   }
    + *
    + *   @Rule(group = "audit")
    + *   void audit(TestCase tc) {
    + *     tc.note("store holds " + store.size() + " keys");
    + *   }
    + *
    + *   @Invariant
    + *   void noLostUpdates(TestCase tc) {
    + *     int total = store.values().stream().mapToInt(Integer::intValue).sum();
    + *     assertEquals(increments.get(), total);
    + *   }
    + * }
    + *
    + * @HegelTest
    + * void counterUnderContention(TestCase tc) {
    + *   Stateful.run(new Counter(tc), tc, Stateful.options().maxConcurrency(4));
    + * }
    + * }
    + * + *

    For each test case the engine draws a concurrency level between {@link + * Options#minConcurrency} and {@link Options#maxConcurrency} (weighted toward the maximum) and the + * driver runs that many worker threads. Execution proceeds in rounds: the engine picks one + * {@linkplain Rule#group() concurrency group} per round, every worker pulls and applies a few of + * that group's rules concurrently, and once every worker has finished the round the sampled + * invariants run on the driving thread. Rules in the same group may therefore overlap in time; + * rules in different groups never do, and invariants never overlap a rule. The step count bounds + * the number of rounds. + * + *

    Rules run concurrently on the same machine object, so any state a rule reads or writes must + * be safe for concurrent access (atomics, {@code synchronized}, concurrent collections). Each rule + * receives its worker's own {@link TestCase}: draw only through it, never retain it past the rule + * or share it with another worker, and use a {@link ConcurrentPool} rather than a {@link Pool} to + * pass generated values between rules. Invariants receive the driving thread's test case. Rules + * must not depend on their relative ordering within a round. + * + *

    When a rule fails in some worker, the other workers finish their round before the failure is + * reported, so a rule that blocks forever hangs the test case as it would sequentially. When + * several workers fail in the same round the lowest-numbered worker's exception is reported and the + * others are noted as dropped. The report groups each round's output by worker under the round's + * header, each line stamped {@code [worker N +X.XXXms]} with the time since the machine started, so + * it can be read across workers. A concurrency bug that depends on thread scheduling may not + * reproduce on replay; the engine then confirms it by repeated replay and reports it with a + * caveat (see {@link Failure#caveat()}). + */ +public final class Stateful { + private Stateful() {} + + /** + * The step count {@link #run(Object, TestCase)} uses: the conventional choice across Hegel + * frontends. + */ + public static final int DEFAULT_STEP_COUNT = 50; + + /** The concurrency group of every {@link Rule @Rule} that names none. */ + public static final String ANONYMOUS_GROUP = ""; + + /** + * How to run a machine: the step count and the concurrency bounds. Immutable; each setter + * returns a new instance. Start from {@link Stateful#options()}. + */ + public static final class Options { + static final Options DEFAULT = new Options(DEFAULT_STEP_COUNT, 1, 1); + + final int stepCount; + final int minConcurrency; + final int maxConcurrency; + + private Options(int stepCount, int minConcurrency, int maxConcurrency) { + this.stepCount = stepCount; + this.minConcurrency = minConcurrency; + this.maxConcurrency = maxConcurrency; + } + + /** + * The target number of steps per test case: rules for a sequential machine, rounds for a + * concurrent one. Every test case runs at least one step and at most {@code stepCount}; + * the engine decides when to stop within that budget. After each step a sampled invariant + * is checked with probability {@code 1 / stepCount}. + * + * @param stepCount the step budget, at least 1 + * @return options with the step count set + * @throws IllegalArgumentException if {@code stepCount} is less than 1 + */ + public Options stepCount(int stepCount) { + if (stepCount < 1) { + throw new IllegalArgumentException("stepCount must be at least 1, got " + stepCount); + } + return new Options(stepCount, minConcurrency, maxConcurrency); + } + + /** + * The fewest worker threads a test case runs. Defaults to 1. + * + * @param minConcurrency the lower concurrency bound, at least 1 + * @return options with the bound set + * @throws IllegalArgumentException if {@code minConcurrency} is less than 1 + */ + public Options minConcurrency(int minConcurrency) { + if (minConcurrency < 1) { + throw new IllegalArgumentException("minConcurrency must be at least 1, got " + minConcurrency); + } + return new Options(stepCount, minConcurrency, maxConcurrency); + } + + /** + * The most worker threads a test case runs. Defaults to 1, which runs the machine + * sequentially on the calling thread; any higher value runs it concurrently (see {@link + * Stateful}), with the engine drawing each test case's level between the bounds. + * + * @param maxConcurrency the upper concurrency bound, at least 1 + * @return options with the bound set + * @throws IllegalArgumentException if {@code maxConcurrency} is less than 1 + */ + public Options maxConcurrency(int maxConcurrency) { + if (maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency must be at least 1, got " + maxConcurrency); + } + return new Options(stepCount, minConcurrency, maxConcurrency); + } + + /** + * @return the step budget per test case + */ + public int stepCount() { + return stepCount; + } + + /** + * @return the lower concurrency bound + */ + public int minConcurrency() { + return minConcurrency; + } + + /** + * @return the upper concurrency bound + */ + public int maxConcurrency() { + return maxConcurrency; + } + } + + /** + * The default options: {@link #DEFAULT_STEP_COUNT} steps, sequential. + * + * @return the defaults, to refine with the {@link Options} setters + */ + public static Options options() { + return Options.DEFAULT; + } + + /** + * Run {@code machine}'s rules and invariants under {@code tc} for up to {@link + * #DEFAULT_STEP_COUNT} steps, sequentially. + * + *

    The machine's {@code @Rule}/{@code @Invariant} methods are discovered reflectively from + * its class (superclass methods are not considered) and ordered by name, so rule numbering is + * stable across JVMs. + * + * @param machine the state machine to drive + * @param tc the current test case + */ + public static void run(Object machine, TestCase tc) { + run(machine, tc, Options.DEFAULT); + } + + /** + * Run {@code machine}'s rules and invariants under {@code tc} for up to {@code stepCount} + * steps, sequentially. Equivalent to {@code run(machine, tc, options().stepCount(stepCount))}. + * + * @param machine the state machine to drive + * @param tc the current test case + * @param stepCount the target number of steps per test case, at least 1 + * @throws IllegalArgumentException if {@code stepCount} is less than 1 + */ + public static void run(Object machine, TestCase tc, int stepCount) { + run(machine, tc, Options.DEFAULT.stepCount(stepCount)); + } + + /** + * Run {@code machine}'s rules and invariants under {@code tc} as {@code options} say: + * sequentially on the calling thread when {@link Options#maxConcurrency} is 1, on worker + * threads otherwise (see {@link Stateful}). + * + * @param machine the state machine to drive + * @param tc the current test case + * @param options the step budget and concurrency bounds + * @throws IllegalArgumentException if the machine has no rules, a rule or invariant has the + * wrong signature or an invalid weight, or {@code minConcurrency} exceeds {@code + * maxConcurrency} + */ + public static void run(Object machine, TestCase tc, Options options) { + if (options.minConcurrency > options.maxConcurrency) { + throw new IllegalArgumentException("minConcurrency (" + options.minConcurrency + + ") must not exceed maxConcurrency (" + options.maxConcurrency + ")"); + } + List rules = annotated(machine, Rule.class); + List invariants = annotated(machine, Invariant.class); + if (rules.isEmpty()) { + throw new IllegalArgumentException( + machine.getClass().getName() + " has no @Rule methods; a state machine needs at least one"); + } + List groupNames = new ArrayList<>(); + long[] groups = groups(rules, groupNames); + DataSource.StateMachine sm = tc.newStateMachine( + names(rules), + groups, + weights(rules), + names(invariants), + alwaysRun(invariants), + options.minConcurrency, + options.maxConcurrency, + options.stepCount); + try { + if (options.maxConcurrency == 1) { + driveSequential(machine, rules, invariants, tc, sm.id()); + } else { + driveConcurrent(machine, rules, invariants, groupNames, tc, sm.id(), sm.concurrency()); + } + } finally { + tc.stateMachineFree(sm.id()); + } + } + + /** + * The engine's round-based protocol, driven sequentially: each round the engine picks the + * current group ({@code next_group}), hands out that round's rules one at a time ({@code + * next_rule} until the join point), and then samples which invariants to check. The start and + * end of the machine are unconditional join points where every invariant runs. + */ + private static void driveSequential( + Object machine, List rules, List invariants, TestCase tc, long machineId) { + if (!invariants.isEmpty()) { + tc.note("Checking invariants on the initial state."); + } + checkInvariants(machine, invariants, tc, machineId, false); + + int step = 0; + while (true) { + tc.startSpan(Label.STATEFUL_RULE); + if (tc.stateMachineNextGroup(machineId) == Abi.STATE_MACHINE_DONE) { + tc.stopSpan(false); + break; + } + // At concurrency 1 the engine hands out one rule per round, but that is engine policy, + // not protocol: pull rules until the join point. + boolean roundRejected = false; + while (true) { + long index = tc.stateMachineNextRule(machineId, 0); + if (index == Abi.STATE_MACHINE_DONE) { + break; + } + Method rule = rules.get(ruleIndex(index, rules)); + step++; + tc.note("Step " + step + ": " + rule.getName()); + try { + invokeMachineMethod(machine, rule, tc); + } catch (AssumeRejected e) { + if (tc.isAborted()) { + // The engine itself concluded the case invalid (e.g. a draw inside the + // rule was rejected): the whole body unwinds, as for any failed assumption. + throw e; + } + // The rule's own precondition failed: tell the engine not to count it as a + // step, discard the round's span so it retries from before the step, and pull + // the next rule. + tc.stateMachineRuleRejected(machineId, 0); + roundRejected = true; + tc.note("Rule stopped early due to violated assumption."); + } catch (RuntimeException | Error e) { + // Everything else — including StopTest, so an out-of-data case is reported as + // an overrun instead of returning normally with a half-applied rule — unwinds + // through the caller. stopSpan is a no-op when the case is already being torn + // down. + tc.stopSpan(false); + throw e; + } + } + tc.stopSpan(roundRejected); + checkInvariants(machine, invariants, tc, machineId, true); + } + + if (!invariants.isEmpty()) { + tc.note("Checking invariants on the final state."); + } + checkInvariants(machine, invariants, tc, machineId, false); + } + + /** + * The same protocol driven by worker threads, mirroring hegel-rust's concurrent runner. The + * calling thread owns the root handle: it advances rounds, clones a fresh per-round handle for + * each worker right after the round's header (so the round's lines land under it), waits for + * every worker to report, absorbs their output, resolves the round's outcome and samples the + * invariants. Workers persist for the whole test case and only ever touch their own handle. + * No spans are opened: the engine owns rule structure through {@code next_rule} / {@code + * rule_rejected}, and a round's draws are spread over several streams. + */ + private static void driveConcurrent( + Object machine, + List rules, + List invariants, + List groupNames, + TestCase tc, + long machineId, + int concurrency) { + long startNanos = System.nanoTime(); + tc.note("Concurrency level: " + concurrency); + if (!invariants.isEmpty()) { + tc.note("Checking invariants on the initial state."); + } + checkInvariants(machine, invariants, tc, machineId, false); + + Worker[] workers = new Worker[concurrency]; + for (int w = 0; w < concurrency; w++) { + workers[w] = new Worker(w, machine, rules, machineId); + } + List handles = new ArrayList<>(); + try { + for (Worker worker : workers) { + worker.thread.start(); + } + int round = 0; + while (true) { + long group = tc.stateMachineNextGroup(machineId); + if (group == Abi.STATE_MACHINE_DONE) { + break; + } + round++; + tc.note("---------------- Round " + round + ": group \"" + groupNames.get((int) group) + + "\" ----------------"); + for (Worker worker : workers) { + handles.add(tc.forWorker(worker.index, startNanos)); + } + for (int w = 0; w < concurrency; w++) { + workers[w].inbox.add(handles.get(w)); + } + WorkerEvent[] events = new WorkerEvent[concurrency]; + for (int w = 0; w < concurrency; w++) { + events[w] = workers[w].await(); + } + // Every worker has reported, so no handle is in use any more: fold the round's + // output into the report and release the clones before judging the round. + for (TestCase handle : handles) { + tc.absorb(handle); + handle.release(); + } + handles.clear(); + resolveRound(events, tc); + checkInvariants(machine, invariants, tc, machineId, true); + } + if (!invariants.isEmpty()) { + tc.note("Checking invariants on the final state."); + } + checkInvariants(machine, invariants, tc, machineId, false); + } finally { + // Stop the workers first: a handle may only be released once its worker is done. + for (Worker worker : workers) { + worker.stop(); + } + for (TestCase handle : handles) { + handle.release(); + } + } + } + + /** + * Judge a finished round from its workers' events, in hegel-rust's precedence: a binding or + * usage error (or a worker that died without reporting) is rethrown first; then an overrun or + * an engine-level rejection concludes the whole case, dropping any failures found alongside + * (an abandoned rule can leave the machine's locks and state inconsistent, so those are not + * trustworthy); otherwise the lowest-numbered failing worker's exception is rethrown as-is and + * the rest are noted as dropped. + */ + private static void resolveRound(WorkerEvent[] events, TestCase tc) { + for (WorkerEvent event : events) { + if (event.kind == WorkerEvent.Kind.CONTROL) { + throw event.error; + } + } + boolean overrun = false; + boolean invalid = false; + for (WorkerEvent event : events) { + overrun |= event.kind == WorkerEvent.Kind.OVERRUN; + invalid |= event.kind == WorkerEvent.Kind.INVALID; + } + Throwable winner = null; + for (int w = 0; w < events.length; w++) { + WorkerEvent event = events[w]; + if (event.kind != WorkerEvent.Kind.FAILED) { + continue; + } + if (overrun || invalid || winner != null) { + tc.note("Dropped concurrent failure from worker " + w + ": " + event.failure); + } else { + winner = event.failure; + } + } + if (overrun) { + throw new StopTest(); + } + if (invalid) { + throw new AssumeRejected(); + } + if (winner instanceof Error error) { + throw error; + } + if (winner != null) { + throw (RuntimeException) winner; + } + } + + /** What a worker reports at the end of a round. */ + private static final class WorkerEvent { + enum Kind { + /** The worker's rule stream is exhausted. */ + DONE, + /** A binding or usage error, or the worker died: {@link #error} is rethrown verbatim. */ + CONTROL, + /** The engine's choice budget ran out ({@link StopTest}). */ + OVERRUN, + /** The engine concluded the case invalid ({@link AssumeRejected} outside a rule's own precondition). */ + INVALID, + /** A rule threw: {@link #failure} is the property's failure. */ + FAILED + } + + static final WorkerEvent DONE = new WorkerEvent(Kind.DONE, null, null); + static final WorkerEvent OVERRUN = new WorkerEvent(Kind.OVERRUN, null, null); + static final WorkerEvent INVALID = new WorkerEvent(Kind.INVALID, null, null); + + final Kind kind; + final RuntimeException error; + /** A {@link RuntimeException} or an {@link Error}: {@link #invokeMachineMethod} wraps everything else. */ + final Throwable failure; + + WorkerEvent(Kind kind, RuntimeException error, Throwable failure) { + this.kind = kind; + this.error = error; + this.failure = failure; + } + + static WorkerEvent control(RuntimeException error) { + return new WorkerEvent(Kind.CONTROL, error, null); + } + + static WorkerEvent failed(Throwable failure) { + return new WorkerEvent(Kind.FAILED, null, failure); + } + } + + /** + * A worker thread: per round it receives a handle through its inbox, pulls and applies rules + * on it until the engine signals its join point, and reports one event through its outbox. + * Whatever happens, it reports — a worker that exits its loop leaves a control error behind — + * so the driving thread never waits forever on a live queue. + */ + private static final class Worker { + /** Inbox sentinel: the test case is over, exit. */ + private static final Object STOP = new Object(); + + final int index; + final Thread thread; + final BlockingQueue inbox = new LinkedBlockingQueue<>(); + final BlockingQueue outbox = new LinkedBlockingQueue<>(); + private final Object machine; + private final List rules; + private final long machineId; + + Worker(int index, Object machine, List rules, long machineId) { + this.index = index; + this.machine = machine; + this.rules = rules; + this.machineId = machineId; + this.thread = new Thread(this::loop, "hegel-worker-" + index); + this.thread.setDaemon(true); + } + + private void loop() { + try { + while (true) { + Object command = inbox.take(); + if (command == STOP) { + return; + } + outbox.add(runRound((TestCase) command)); + } + } catch (InterruptedException e) { + // Interrupted between rounds (a rule set the flag): fall through and report it. + Thread.currentThread().interrupt(); + } finally { + // Whatever ended the loop, leave an event behind so the driver never waits forever. + outbox.add(WorkerEvent.control(new HegelException( + "internal error: concurrent worker " + index + " exited without reporting its round"))); + } + } + + /** Pull and apply rules on {@code wtc} until the engine ends this worker's round. */ + private WorkerEvent runRound(TestCase wtc) { + try { + while (true) { + long index = wtc.stateMachineNextRule(machineId, this.index); + if (index == Abi.STATE_MACHINE_DONE) { + return WorkerEvent.DONE; + } + Method rule = rules.get(ruleIndex(index, rules)); + wtc.note("Rule: " + rule.getName()); + try { + invokeMachineMethod(machine, rule, wtc); + } catch (AssumeRejected e) { + if (wtc.isAborted()) { + // The engine concluded the case invalid inside the rule, not the + // rule's own precondition: classified below. + throw e; + } + // The engine does not count the slot; the next next_rule retries it. + wtc.stateMachineRuleRejected(machineId, this.index); + wtc.note("Rule stopped early due to violated assumption."); + } + } + } catch (AssumeRejected e) { + return WorkerEvent.INVALID; + } catch (StopTest e) { + return WorkerEvent.OVERRUN; + } catch (LibhegelException | IllegalArgumentException e) { + return WorkerEvent.control(e); + } catch (Throwable e) { + return WorkerEvent.failed(e); + } + } + + /** Block until this worker reports its round. */ + WorkerEvent await() { + try { + return outbox.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new HegelException("interrupted while waiting for concurrent worker " + index, e); + } + } + + /** Tell the worker the test case is over and wait for it to exit. */ + void stop() { + inbox.add(STOP); + boolean interrupted = false; + while (true) { + try { + thread.join(); + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + /** Run the invariants at a join point: all of them, or only those the engine samples in. */ + private static void checkInvariants( + Object machine, List invariants, TestCase tc, long machineId, boolean sampled) { + for (int i = 0; i < invariants.size(); i++) { + if (sampled && !tc.stateMachineShouldCheckInvariant(machineId, i)) { + continue; + } + invokeMachineMethod(machine, invariants.get(i), tc); + } + } + + private static int ruleIndex(long index, List rules) { + if (index < 0 || index >= rules.size()) { + throw new HegelException("internal error: state machine chose out-of-range rule index " + index); + } + return (int) index; + } + + /** + * Invoke a rule/invariant method, unwrapping reflection's exception wrapper so the method's own + * exception propagates. A checked exception (which a rule may declare but this driver cannot + * rethrow as-is) fails the property wrapped in a {@link RuntimeException} carrying it as cause. + */ + private static void invokeMachineMethod(Object machine, Method method, TestCase tc) { + try { + invokeAccessible(machine, method, tc); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Error error) { + throw error; + } + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + throw new RuntimeException(cause); + } + } + + @Generated // IllegalAccessException is unreachable: annotated() made every machine method accessible. + private static void invokeAccessible(Object machine, Method method, TestCase tc) throws InvocationTargetException { + try { + method.invoke(machine, tc); + } catch (IllegalAccessException e) { + throw new HegelException("failed to invoke " + method + "; is it accessible?", e); + } + } + + private static List annotated(Object machine, Class kind) { + List methods = new ArrayList<>(); + for (Method m : machine.getClass().getDeclaredMethods()) { + if (!m.isAnnotationPresent(kind)) { + continue; + } + if (m.getParameterCount() != 1 || m.getParameterTypes()[0] != TestCase.class) { + throw new IllegalArgumentException( + "@" + kind.getSimpleName() + " method " + m + " must take a single TestCase parameter"); + } + m.setAccessible(true); + methods.add(m); + } + // getDeclaredMethods order is unspecified; sort so rule numbering is deterministic. + methods.sort(Comparator.comparing(Method::getName)); + return methods; + } + + private static List names(List methods) { + return methods.stream().map(Method::getName).toList(); + } + + /** + * The rules' concurrency-group ids, parallel to {@code rules}: distinct group names numbered + * from 0 in first-appearance order, appended to {@code groupNames} so the id indexes it. A + * sequential machine's all-anonymous rules land in one group, id 0. + */ + private static long[] groups(List rules, List groupNames) { + long[] ids = new long[rules.size()]; + for (int i = 0; i < ids.length; i++) { + String group = rules.get(i).getAnnotation(Rule.class).group(); + if (group.isEmpty()) { + group = ANONYMOUS_GROUP; + } + int id = groupNames.indexOf(group); + if (id < 0) { + id = groupNames.size(); + groupNames.add(group); + } + ids[i] = id; + } + return ids; + } + + /** + * The rules' selection weights, parallel to {@code rules}, or {@code null} — the engine's + * all-equal default — when no rule asks for anything but weight 1. + */ + private static double[] weights(List rules) { + double[] weights = new double[rules.size()]; + boolean weighted = false; + for (int i = 0; i < weights.length; i++) { + Method rule = rules.get(i); + double w = rule.getAnnotation(Rule.class).weight(); + if (!(Double.isFinite(w) && w > 0)) { + throw new IllegalArgumentException( + "@Rule weight of " + rule.getName() + " must be finite and positive, got " + w); + } + weights[i] = w; + weighted |= w != 1.0; + } + return weighted ? weights : null; + } + + private static boolean[] alwaysRun(List invariants) { + boolean[] flags = new boolean[invariants.size()]; + for (int i = 0; i < flags.length; i++) { + flags[i] = invariants.get(i).getAnnotation(Invariant.class).alwaysRun(); + } + return flags; + } +} diff --git a/src/main/java/dev/hegel/StopTest.java b/shared/src/main/java/dev/hegel/StopTest.java similarity index 100% rename from src/main/java/dev/hegel/StopTest.java rename to shared/src/main/java/dev/hegel/StopTest.java diff --git a/shared/src/main/java/dev/hegel/StringGeneratorHandle.java b/shared/src/main/java/dev/hegel/StringGeneratorHandle.java new file mode 100644 index 0000000..77ddf3d --- /dev/null +++ b/shared/src/main/java/dev/hegel/StringGeneratorHandle.java @@ -0,0 +1,40 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Libhegel; +import java.lang.ref.Cleaner; + +/** + * An opaque engine string-generator handle ({@code hegel_string_generator_t*}) plus the binding it + * was built by. + * + *

    Built once per generator configuration (validating all parameters eagerly) and cached by the + * string-shaped generators in {@code dev.hegel.generators}, so the alphabet/pattern work happens + * once, not per draw. The handle is immutable after construction and may be shared across test + * cases and threads. The engine-side allocation is released when this wrapper becomes unreachable + * (via a {@link Cleaner}), since by then no draw can use it again. + * + * @hidden + */ +public final class StringGeneratorHandle { + private static final Cleaner CLEANER = Cleaner.create(); + + final Libhegel lib; + final long handle; + + StringGeneratorHandle(Libhegel lib, long handle) { + this.lib = lib; + this.handle = handle; + CLEANER.register(this, new Free(lib, handle)); + } + + /** + * The deferred release of the engine-side allocation. A record (not a lambda capturing {@code + * this}) so the cleanable never keeps its own handle reachable. + */ + record Free(Libhegel lib, long handle) implements Runnable { + @Override + public void run() { + lib.stringGeneratorFree(handle); + } + } +} diff --git a/shared/src/main/java/dev/hegel/TestCase.java b/shared/src/main/java/dev/hegel/TestCase.java new file mode 100644 index 0000000..0e40dca --- /dev/null +++ b/shared/src/main/java/dev/hegel/TestCase.java @@ -0,0 +1,602 @@ +package dev.hegel; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * The handle a property test body uses to draw values and steer the engine. + * + *

    An instance is supplied to the test body for each case the engine runs. Draw values with + * {@link #draw(Generator)}, reject uninteresting inputs with {@link #assume(boolean)}, attach debug + * context with {@link #note(String)}, and guide the search with {@link #target(double)}. + * + *

    On an execution the engine stamped for capture ({@link #isFinal()}) — the final replay of a + * minimal failing example among them — each top-level {@code draw} and each note is recorded, and + * if the case fails and the engine reports that failure, they are handed to the run's {@link + * Reporter} (the default prints {@code x = 42;}) and carried by the resulting {@link Failure}, so + * the counterexample is readable. Under {@link Verbosity#VERBOSE} or higher the reporter sees every + * case's draws and notes live. A label drawn more than once in a case is numbered from its second + * use ({@code x}, {@code x_2}, {@code x_3}); unlabelled draws are {@code draw_1}, {@code draw_2}, + * ... + * + *

    Frontends implementing their own composite generators enclose their draws in a labelled + * {@link #span(long, Supplier) span} so the engine can shrink the structure they build. + */ +public final class TestCase { + private final DataSource source; + /** Whether the engine stamped this execution for capture: its draws and notes are recorded. */ + private final boolean captured; + /** Whether draws and notes reach the reporter live (a verbose run). */ + private final boolean reporting; + + private final Reporter reporter; + /** + * The root handle of this test case, whose draw-name counter every handle of the case shares: + * {@code this} for the root, the root for a worker handle (see {@link #forWorker}). + */ + private final TestCase root; + /** The concurrent worker this is a per-round handle for, or {@code -1} for the root. */ + private final int worker; + /** Reference instant for a worker handle's line stamps ({@link System#nanoTime()}). */ + private final long startNanos; + + private final Map draws = new LinkedHashMap<>(); + private final List notes = new ArrayList<>(); + /** + * The recorded draws and notes in report order. On a worker handle this is the round's + * buffer, absorbed by the root at the join point ({@link #absorb}). + */ + private final List events = new ArrayList<>(); + /** + * Uses per draw name, for numbering repeats ({@code x}, {@code x_2}, ...; {@code draw_N}). + * Only the root's is used; worker handles go through {@link #nextName} on the root. + */ + private final Map nameUses = new HashMap<>(); + /** Notes made while a top-level draw is in progress; flushed after that draw's line. */ + private final List pendingNotes = new ArrayList<>(); + + private int drawDepth; + + /** A recorded draw ({@code message == null}) or note, as the reporter would be told about it. */ + private static final class Event { + /** The draw's name as reported (stamped with the worker prefix on a worker handle). */ + final String name; + /** The draw's plain name, the key it is recorded under. */ + final String key; + + final Object value; + final String message; + + Event(String name, String key, Object value, String message) { + this.name = name; + this.key = key; + this.value = value; + this.message = message; + } + + void replay(Reporter reporter, boolean finalReplay) { + if (message != null) { + reporter.note(message, finalReplay); + } else { + reporter.draw(name, value, finalReplay); + } + } + } + + TestCase(DataSource source, boolean captured, Reporter reporter) { + this(source, captured, false, reporter); + } + + TestCase(DataSource source, boolean captured, boolean verbose, Reporter reporter) { + this.source = source; + this.captured = captured; + this.reporting = verbose; + this.reporter = reporter; + this.root = this; + this.worker = -1; + this.startNanos = 0; + } + + private TestCase(DataSource source, TestCase root, int worker, long startNanos) { + this.source = source; + this.captured = root.captured; + this.reporting = root.reporting; + this.reporter = root.reporter; + this.root = root; + this.worker = worker; + this.startNanos = startNanos; + } + + /** + * A handle for concurrent worker {@code worker} to draw through for one round of a stateful + * machine: an independent choice stream of the same case (see {@link + * DataSource#cloneForWorker}) whose draws and notes are buffered, stamped {@code [worker N + * +X.XXXms]} with the time since {@code startNanos}, and handed to this root at the join point + * by {@link #absorb}. Release it with {@link #release()} once the round is over. + */ + TestCase forWorker(int worker, long startNanos) { + return new TestCase(source.cloneForWorker(worker), this, worker, startNanos); + } + + /** The worker this handle belongs to, or {@code -1} for the root. */ + int worker() { + return worker; + } + + /** Free the clone handle behind a worker handle from {@link #forWorker}. */ + void release() { + source.release(); + } + + /** + * Take over a worker handle's buffered draws and notes: record them here (on a captured + * execution) and hand them to the reporter (on a verbose one), in the order the worker made + * them. Called on the root at a join point, once the worker has finished its round, so the + * reporter only ever hears from the driving thread. + */ + void absorb(TestCase workerHandle) { + for (Event event : workerHandle.events) { + record(event); + } + workerHandle.events.clear(); + } + + /** The stamp a worker handle's lines carry; empty on the root. */ + private String prefix() { + if (worker < 0) { + return ""; + } + double elapsedMillis = (System.nanoTime() - startNanos) / 1e6; + return String.format(Locale.ROOT, "[worker %d +%.3fms] ", worker, elapsedMillis); + } + + /** + * Deliver a draw or note: buffered on a worker handle until the root absorbs it; on the root, + * kept for the report when captured and reported live when verbose. + */ + private void record(Event event) { + if (worker >= 0) { + events.add(event); + return; + } + if (captured) { + events.add(event); + if (event.message != null) { + notes.add(event.message); + } else { + draws.put(event.key, event.value); + } + } + if (reporting) { + event.replay(reporter, false); + } + } + + /** + * Whether the engine stamped this execution as one a failure report can be built from — the + * final replay of a minimal counterexample (or of a {@link Settings#reproduceFailure(String)} + * blob), the replays that confirm a discovered failure — as opposed to one of the many cases it + * runs while generating and shrinking. Draws and notes are recorded only on a stamped + * execution, and reported only if it fails and the engine reports that failure; a test body can + * use this to do its own expensive diagnostics only where they may be seen. + * + * @return {@code true} on an execution stamped for capture + */ + public boolean isFinal() { + return captured; + } + + /** + * Draw a value from {@code generator}. + * + * @param generator the generator to draw from + * @param the value type + * @return the generated value + */ + public T draw(Generator generator) { + return draw(generator, null); + } + + /** + * Draw a value, naming it {@code label} in the falsifying-example output. + * + * @param generator the generator to draw from + * @param label the variable name to show in counterexample output + * @param the value type + * @return the generated value + */ + public T draw(Generator generator, String label) { + boolean top = drawDepth == 0; + drawDepth++; + T value; + boolean completed = false; + try { + value = generator.doDraw(this); + completed = true; + } finally { + drawDepth--; + if (top && !completed) { + // No draw line will follow; do not lose the notes made on the way to the failure. + flushPendingNotes(); + } + } + if (top) { + if (captured || reporting) { + String name = root.nextName(label); + record(new Event(prefix() + name, name, value, null)); + } + flushPendingNotes(); + } + return value; + } + + /** + * The name a top-level draw reports under: a label prints bare the first time and numbered from + * its second use ({@code x}, {@code x_2}, ...); an unlabelled draw is {@code draw_N}, counting + * unlabelled draws only. Names are unique across the whole case — worker handles number + * through the root — hence synchronized: concurrent workers name their draws at the same time. + */ + private synchronized String nextName(String label) { + String base = label != null ? label : "draw"; + int uses = nameUses.merge(base, 1, Integer::sum); + if (label == null) { + return base + "_" + uses; + } + return uses == 1 ? label : label + "_" + uses; + } + + private void flushPendingNotes() { + for (String message : pendingNotes) { + emitNote(message); + } + pendingNotes.clear(); + } + + private void emitNote(String message) { + record(new Event(null, null, null, prefix() + message)); + } + + /** + * 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. + * + * @param condition the precondition that must hold + */ + public void assume(boolean condition) { + if (!condition) { + throw new AssumeRejected(); + } + } + + /** + * Record a debug message, reported with the counterexample when this case's failure is + * reported (and live on every case under {@link Verbosity#VERBOSE}). A note made while a + * top-level draw is in progress — from inside a composite generator — is reported after that + * draw's value, never before it. + * + * @param message the message to record + */ + public void note(String message) { + if (!(captured || reporting)) { + return; + } + if (drawDepth > 0) { + pendingNotes.add(message); + } else { + emitNote(message); + } + } + + /** + * Provide a score for the coverage-guided search; higher is treated as more interesting. + * + * @param value the observation + */ + public void target(double value) { + target(value, ""); + } + + /** + * Provide a labelled score for the coverage-guided search. + * + * @param value the observation + * @param label groups observations for multi-objective search + */ + public void target(double value, String label) { + source.target(value, label); + } + + // --- engine primitives used by generators in dev.hegel.generators (public for cross-package + // access; not part of the user-facing API) --- + + /** @hidden */ + public boolean generateBoolean(double p) { + return source.generateBoolean(p); + } + + /** @hidden */ + public long generateInteger(long min, long max) { + return source.generateInteger(min, max); + } + + /** @hidden */ + public double generateFloat( + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude) { + return source.generateFloat( + width, min, max, allowNan, allowInfinity, excludeMin, excludeMax, smallestNonzeroMagnitude); + } + + /** @hidden */ + public byte[] generateBytes(long minSize, long maxSize) { + return source.generateBytes(minSize, maxSize); + } + + /** @hidden */ + public String generateString(StringGeneratorHandle generator) { + return source.generateString(generator); + } + + /** @hidden */ + public LocalDate generateDate(LocalDate min, LocalDate max) { + return source.generateDate(min, max); + } + + /** @hidden */ + public LocalTime generateTime(LocalTime min, LocalTime max) { + return source.generateTime(min, max); + } + + /** @hidden */ + public LocalDateTime generateDatetime(LocalDateTime min, LocalDateTime max) { + return source.generateDatetime(min, max); + } + + /** @hidden */ + public UUID generateUuid(Integer version) { + return source.generateUuid(version); + } + + /** @hidden */ + public byte[] generateIpv4() { + return source.generateIpv4(); + } + + /** @hidden */ + public byte[] generateIpv6() { + return source.generateIpv6(); + } + + /** @hidden */ + public StringGeneratorHandle textGenerator( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters) { + return source.textGenerator( + minSize, + maxSize, + codec, + minCodepoint, + maxCodepoint, + categories, + excludeCategories, + includeCharacters, + excludeCharacters); + } + + /** @hidden */ + public StringGeneratorHandle regexGenerator(String pattern, boolean fullmatch, StringGeneratorHandle alphabet) { + return source.regexGenerator(pattern, fullmatch, alphabet); + } + + /** @hidden */ + public StringGeneratorHandle emailGenerator() { + return source.emailGenerator(); + } + + /** @hidden */ + public StringGeneratorHandle urlGenerator() { + return source.urlGenerator(); + } + + /** @hidden */ + public StringGeneratorHandle domainGenerator(long maxLength) { + return source.domainGenerator(maxLength); + } + + /** @hidden */ + public boolean ownsStringGenerator(StringGeneratorHandle generator) { + return source.ownsStringGenerator(generator); + } + + /** + * Draw a structured value inside a labelled span: open the span, run {@code body}, and close + * the span whether or not the body completes. This is how composite generators tell the engine + * which draws belong together, so it can shrink the structure as a unit; {@link Label} lists + * the engine's structural labels and {@link Label#of(String)} mints custom ones. + * + * @param label the span's label + * @param body the draws making up the value + * @param the value type + * @return the body's result + */ + public T span(long label, Supplier body) { + startSpan(label); + try { + return body.get(); + } finally { + stopSpan(false); + } + } + + /** + * Open a labelled span. Every {@code startSpan} must be matched by a {@link #stopSpan(boolean)} + * on every exit path, including exceptional ones; prefer {@link #span(long, Supplier)}, which + * handles that. + * + * @param label the span's label (see {@link Label}) + */ + public void startSpan(long label) { + source.startSpan(label); + } + + /** + * Close the innermost open span. Passing {@code discard = true} tells the engine the span's + * draws were rejected (for example, a filtered value that failed its predicate) and will be + * retried. A no-op once the case has been concluded by the engine, so span-closing {@code + * finally} blocks are safe while a case unwinds. + * + * @param discard whether the span's draws were rejected + */ + public void stopSpan(boolean discard) { + source.stopSpan(discard); + } + + /** @hidden */ + public long newCollection(long minSize, long maxSize) { + return source.newCollection(minSize, maxSize); + } + + /** @hidden */ + public boolean collectionMore(long id) { + return source.collectionMore(id); + } + + /** @hidden */ + public void collectionReject(long id, String why) { + source.collectionReject(id, why); + } + + // --- stateful-testing primitives, used by Stateful and Pool in this package --- + + long newPool() { + return source.newPool(); + } + + long poolAdd(long poolId) { + return source.poolAdd(poolId); + } + + long poolGenerate(long poolId, boolean consume) { + return source.poolGenerate(poolId, consume); + } + + DataSource.StateMachine newStateMachine( + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + int stepCount) { + return source.newStateMachine( + ruleNames, + ruleGroups, + ruleWeights, + invariantNames, + invariantAlwaysCheck, + minConcurrency, + maxConcurrency, + stepCount); + } + + long stateMachineNextGroup(long stateMachineId) { + return source.stateMachineNextGroup(stateMachineId); + } + + long stateMachineNextRule(long stateMachineId, long workerIndex) { + return source.stateMachineNextRule(stateMachineId, workerIndex); + } + + void stateMachineRuleRejected(long stateMachineId, long workerIndex) { + source.stateMachineRuleRejected(stateMachineId, workerIndex); + } + + boolean stateMachineShouldCheckInvariant(long stateMachineId, long invariantIndex) { + return source.stateMachineShouldCheckInvariant(stateMachineId, invariantIndex); + } + + void stateMachineFree(long stateMachineId) { + source.stateMachineFree(stateMachineId); + } + + /** Whether the engine has concluded this case (overrun or invalid), so its body must unwind. */ + boolean isAborted() { + return source.isAborted(); + } + + /** The top-level draws recorded on a captured execution, in draw order. */ + Map draws() { + return draws; + } + + /** The notes recorded on a captured execution, in order. */ + List notes() { + return notes; + } + + /** Hand the recorded draws and notes to {@code reporter}, in report order, flagged as a replay. */ + void replayTo(Reporter reporter) { + for (Event event : events) { + event.replay(reporter, true); + } + } + + static String repr(Object value) { + if (value == null) { + return "null"; + } + if (value instanceof String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + if (value instanceof byte[] b) { + return Arrays.toString(b); + } + if (value instanceof List list) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(repr(list.get(i))); + } + return sb.append("]").toString(); + } + if (value instanceof Map map) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append(repr(e.getKey())).append(": ").append(repr(e.getValue())); + } + return sb.append("}").toString(); + } + return String.valueOf(value); + } +} diff --git a/src/main/java/dev/hegel/Tuple2.java b/shared/src/main/java/dev/hegel/Tuple2.java similarity index 100% rename from src/main/java/dev/hegel/Tuple2.java rename to shared/src/main/java/dev/hegel/Tuple2.java diff --git a/src/main/java/dev/hegel/Tuple3.java b/shared/src/main/java/dev/hegel/Tuple3.java similarity index 100% rename from src/main/java/dev/hegel/Tuple3.java rename to shared/src/main/java/dev/hegel/Tuple3.java diff --git a/src/main/java/dev/hegel/Tuple4.java b/shared/src/main/java/dev/hegel/Tuple4.java similarity index 100% rename from src/main/java/dev/hegel/Tuple4.java rename to shared/src/main/java/dev/hegel/Tuple4.java diff --git a/src/main/java/dev/hegel/Tuple5.java b/shared/src/main/java/dev/hegel/Tuple5.java similarity index 100% rename from src/main/java/dev/hegel/Tuple5.java rename to shared/src/main/java/dev/hegel/Tuple5.java diff --git a/src/main/java/dev/hegel/Tuple6.java b/shared/src/main/java/dev/hegel/Tuple6.java similarity index 100% rename from src/main/java/dev/hegel/Tuple6.java rename to shared/src/main/java/dev/hegel/Tuple6.java diff --git a/src/main/java/dev/hegel/Tuple7.java b/shared/src/main/java/dev/hegel/Tuple7.java similarity index 100% rename from src/main/java/dev/hegel/Tuple7.java rename to shared/src/main/java/dev/hegel/Tuple7.java diff --git a/src/main/java/dev/hegel/Tuple8.java b/shared/src/main/java/dev/hegel/Tuple8.java similarity index 100% rename from src/main/java/dev/hegel/Tuple8.java rename to shared/src/main/java/dev/hegel/Tuple8.java diff --git a/src/main/java/dev/hegel/Verbosity.java b/shared/src/main/java/dev/hegel/Verbosity.java similarity index 93% rename from src/main/java/dev/hegel/Verbosity.java rename to shared/src/main/java/dev/hegel/Verbosity.java index 1633d82..4ee2ae0 100644 --- a/src/main/java/dev/hegel/Verbosity.java +++ b/shared/src/main/java/dev/hegel/Verbosity.java @@ -1,5 +1,7 @@ package dev.hegel; +import dev.hegel.lowlevel.Abi; + /** Engine output verbosity. */ public enum Verbosity { /** Nothing besides the final result. */ diff --git a/src/main/java/dev/hegel/generators/BinaryGenerator.java b/shared/src/main/java/dev/hegel/generators/BinaryGenerator.java similarity index 64% rename from src/main/java/dev/hegel/generators/BinaryGenerator.java rename to shared/src/main/java/dev/hegel/generators/BinaryGenerator.java index f2c35e0..00f03fa 100644 --- a/src/main/java/dev/hegel/generators/BinaryGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/BinaryGenerator.java @@ -1,16 +1,13 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; /** * Generates {@code byte[]} values with length in an inclusive {@code [minSize, maxSize]} range. - * Always basic (one engine call). * - *

    The default is any length; narrow it with the fluent {@link #minSize(int)} / {@link - * #maxSize(int)} methods. + *

    Lengths default to {@code [0, 100]} (or {@code [minSize, minSize + 100]} for a larger + * minimum); set an explicit {@link #maxSize(int)} for longer arrays. */ public final class BinaryGenerator implements Generator { private final long minSize; @@ -40,11 +37,7 @@ public BinaryGenerator maxSize(int maxSize) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = CBORObject.NewMap().Add("type", "binary").Add("min_size", minSize); - if (maxSize != Abi.UNBOUNDED) { - schema.Add("max_size", maxSize); - } - return new BasicGenerator<>(schema, Cbor::asBytes); + public byte[] doDraw(TestCase tc) { + return tc.generateBytes(minSize, Sizes.resolveMax(minSize, maxSize, TextGenerator.DEFAULT_MAX_SIZE)); } } diff --git a/shared/src/main/java/dev/hegel/generators/BooleanGenerator.java b/shared/src/main/java/dev/hegel/generators/BooleanGenerator.java new file mode 100644 index 0000000..45e247a --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/BooleanGenerator.java @@ -0,0 +1,15 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; + +/** + * Generates {@code true} or {@code false} with equal probability. + */ +public final class BooleanGenerator implements Generator { + /** @hidden */ + @Override + public Boolean doDraw(TestCase tc) { + return tc.generateBoolean(0.5); + } +} diff --git a/src/main/java/dev/hegel/generators/CompositeGenerator.java b/shared/src/main/java/dev/hegel/generators/CompositeGenerator.java similarity index 91% rename from src/main/java/dev/hegel/generators/CompositeGenerator.java rename to shared/src/main/java/dev/hegel/generators/CompositeGenerator.java index 2d582f5..7c39e9a 100644 --- a/src/main/java/dev/hegel/generators/CompositeGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/CompositeGenerator.java @@ -1,7 +1,7 @@ package dev.hegel.generators; -import dev.hegel.Abi; import dev.hegel.Generator; +import dev.hegel.Label; import dev.hegel.TestCase; import java.util.function.Function; @@ -21,7 +21,7 @@ public CompositeGenerator(Function body) { @Override public T doDraw(TestCase tc) { - tc.startSpan(Abi.LABEL_COMPOSITE); + tc.startSpan(Label.COMPOSITE); try { return body.apply(tc); } finally { diff --git a/shared/src/main/java/dev/hegel/generators/ConstantGenerator.java b/shared/src/main/java/dev/hegel/generators/ConstantGenerator.java new file mode 100644 index 0000000..96bf44c --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/ConstantGenerator.java @@ -0,0 +1,23 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; + +/** + * Always generates the same value, drawing nothing from the engine. + * + * @param the constant value type + */ +public final class ConstantGenerator implements Generator { + private final T value; + + public ConstantGenerator(T value) { + this.value = value; + } + + /** @hidden */ + @Override + public T doDraw(TestCase tc) { + return value; + } +} diff --git a/shared/src/main/java/dev/hegel/generators/DateGenerator.java b/shared/src/main/java/dev/hegel/generators/DateGenerator.java new file mode 100644 index 0000000..565f2a5 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/DateGenerator.java @@ -0,0 +1,63 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; +import java.time.LocalDate; + +/** + * Generates {@link LocalDate} values within an inclusive {@code [min, max]} range. + * + *

    The default range is {@code 0001-01-01} to {@code 9999-12-31}; narrow it with the fluent + * {@link #min(LocalDate)} / {@link #max(LocalDate)} methods. Values shrink toward 2000-01-01, or + * the nearest bound when that is out of range. + */ +public final class DateGenerator implements Generator { + static final LocalDate DEFAULT_MIN = LocalDate.of(1, 1, 1); + static final LocalDate DEFAULT_MAX = LocalDate.of(9999, 12, 31); + private static final int MAX_ENGINE_YEAR = 999_999; + + private final LocalDate min; + private final LocalDate max; + + public DateGenerator() { + this(DEFAULT_MIN, DEFAULT_MAX); + } + + public DateGenerator(LocalDate min, LocalDate max) { + if (min.isAfter(max)) { + throw new IllegalArgumentException("dates: min (" + min + ") > max (" + max + ")"); + } + validateYear(min); + validateYear(max); + this.min = min; + this.max = max; + } + + private static void validateYear(LocalDate date) { + if (date.getYear() < -MAX_ENGINE_YEAR || date.getYear() > MAX_ENGINE_YEAR) { + throw new IllegalArgumentException("dates: year of " + date + " is outside [-999999, 999999]"); + } + } + + /** + * @param min the inclusive lower bound + * @return a copy with the lower bound set + */ + public DateGenerator min(LocalDate min) { + return new DateGenerator(min, max); + } + + /** + * @param max the inclusive upper bound + * @return a copy with the upper bound set + */ + public DateGenerator max(LocalDate max) { + return new DateGenerator(min, max); + } + + /** @hidden */ + @Override + public LocalDate doDraw(TestCase tc) { + return tc.generateDate(min, max); + } +} diff --git a/src/main/java/dev/hegel/generators/DateTimeGenerator.java b/shared/src/main/java/dev/hegel/generators/DateTimeGenerator.java similarity index 52% rename from src/main/java/dev/hegel/generators/DateTimeGenerator.java rename to shared/src/main/java/dev/hegel/generators/DateTimeGenerator.java index 91b86db..7734331 100644 --- a/src/main/java/dev/hegel/generators/DateTimeGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/DateTimeGenerator.java @@ -1,9 +1,8 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; import dev.hegel.Generator; import dev.hegel.Generators; +import dev.hegel.TestCase; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -11,8 +10,12 @@ import java.time.ZonedDateTime; /** - * Generates {@link LocalDateTime} values (the engine's offset-free {@code - * YYYY-MM-DDTHH:MM:SS[.ffffff]} output). Always basic (one engine call). + * Generates {@link LocalDateTime} values within an inclusive {@code [min, max]} range, at + * nanosecond resolution. + * + *

    The default range is {@code 0001-01-01T00:00} to {@code 9999-12-31T23:59:59.999999999}; narrow it + * with the fluent {@link #min(LocalDateTime)} / {@link #max(LocalDateTime)} methods. Values shrink + * toward 2000-01-01T00:00:00, or the nearest bound when that is out of range. * *

    Attach a timezone to produce zone-aware values: {@link #timezones(Generator)} pairs each * generated wall-clock time with a {@link ZoneId} to make a DST-aware {@link ZonedDateTime}, and @@ -20,14 +23,51 @@ * OffsetDateTime}. */ public final class DateTimeGenerator implements Generator { + private final LocalDateTime min; + private final LocalDateTime max; + + public DateTimeGenerator() { + this( + LocalDateTime.of(DateGenerator.DEFAULT_MIN, java.time.LocalTime.MIDNIGHT), + LocalDateTime.of(DateGenerator.DEFAULT_MAX, java.time.LocalTime.MAX)); + } + + public DateTimeGenerator(LocalDateTime min, LocalDateTime max) { + validateYear(min); + validateYear(max); + if (min.isAfter(max)) { + throw new IllegalArgumentException("datetimes: min (" + min + ") > max (" + max + ")"); + } + this.min = min; + this.max = max; + } + + private static void validateYear(LocalDateTime dt) { + if (dt.getYear() < -999_999 || dt.getYear() > 999_999) { + throw new IllegalArgumentException("datetimes: year of " + dt + " is outside [-999999, 999999]"); + } + } - public DateTimeGenerator() {} + /** + * @param min the inclusive lower bound + * @return a copy with the lower bound set + */ + public DateTimeGenerator min(LocalDateTime min) { + return new DateTimeGenerator(min, max); + } + + /** + * @param max the inclusive upper bound + * @return a copy with the upper bound set + */ + public DateTimeGenerator max(LocalDateTime max) { + return new DateTimeGenerator(min, max); + } /** @hidden */ @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>( - CBORObject.NewMap().Add("type", "datetime"), raw -> LocalDateTime.parse(Cbor.asString(raw))); + public LocalDateTime doDraw(TestCase tc) { + return tc.generateDatetime(min, max); } /** diff --git a/src/main/java/dev/hegel/generators/Deferred.java b/shared/src/main/java/dev/hegel/generators/Deferred.java similarity index 100% rename from src/main/java/dev/hegel/generators/Deferred.java rename to shared/src/main/java/dev/hegel/generators/Deferred.java diff --git a/src/main/java/dev/hegel/generators/Derive.java b/shared/src/main/java/dev/hegel/generators/Derive.java similarity index 97% rename from src/main/java/dev/hegel/generators/Derive.java rename to shared/src/main/java/dev/hegel/generators/Derive.java index 79d94f1..c4efcb0 100644 --- a/src/main/java/dev/hegel/generators/Derive.java +++ b/shared/src/main/java/dev/hegel/generators/Derive.java @@ -67,6 +67,9 @@ private static Generator scalar(Class cls) { if (cls == String.class) { return Generators.text(); } + if (cls == java.util.UUID.class) { + return Generators.uuids(); + } if (cls == byte[].class) { return Generators.binary(); } diff --git a/shared/src/main/java/dev/hegel/generators/DomainGenerator.java b/shared/src/main/java/dev/hegel/generators/DomainGenerator.java new file mode 100644 index 0000000..da38186 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/DomainGenerator.java @@ -0,0 +1,39 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; + +/** + * Generates syntactically valid domain names. The maximum length of the fully-qualified name + * defaults to 255 and can be lowered with {@link #maxLength(int)}. + */ +public final class DomainGenerator implements Generator { + private final int maxLength; + private final HandleCache cache = new HandleCache(); + + public DomainGenerator() { + this(255); + } + + private DomainGenerator(int maxLength) { + if (maxLength < 4 || maxLength > 255) { + throw new IllegalArgumentException("domains: maxLength must be in [4, 255], got " + maxLength); + } + this.maxLength = maxLength; + } + + /** + * @param maxLength the maximum total length of the fully-qualified domain name, in {@code [4, + * 255]} + * @return a copy with the maximum length set + */ + public DomainGenerator maxLength(int maxLength) { + return new DomainGenerator(maxLength); + } + + /** @hidden */ + @Override + public String doDraw(TestCase tc) { + return tc.generateString(cache.get(tc, t -> t.domainGenerator(maxLength))); + } +} diff --git a/src/main/java/dev/hegel/generators/DoubleGenerator.java b/shared/src/main/java/dev/hegel/generators/DoubleGenerator.java similarity index 52% rename from src/main/java/dev/hegel/generators/DoubleGenerator.java rename to shared/src/main/java/dev/hegel/generators/DoubleGenerator.java index 9413197..ed738f4 100644 --- a/src/main/java/dev/hegel/generators/DoubleGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/DoubleGenerator.java @@ -1,35 +1,52 @@ package dev.hegel.generators; -import dev.hegel.Cbor; import dev.hegel.Generator; import dev.hegel.Generators; +import dev.hegel.TestCase; /** - * Generates 64-bit {@code double} values with full control over bounds and special values. Always - * basic (one engine call). For 32-bit {@code float} values use {@link FloatGenerator} ({@link - * Generators#floats()}). + * Generates 64-bit {@code double} values with full control over bounds and special values. For + * 32-bit {@code float} values use {@link FloatGenerator} ({@link Generators#floats()}). * *

    Defaults mirror the engine. With no bounds, NaN and the infinities are allowed. Setting any * bound excludes NaN; setting both bounds also excludes the infinities (a single bound - * still allows the infinity on the open side). {@code allowNan}/{@code allowInfinity} override - * these defaults where the combination is valid. Validation of conflicting options happens at - * construction time. + * still allows the infinity on the open side). Subnormal values are allowed whenever the bounds + * admit any; disable them with {@link #allowSubnormal(boolean) allowSubnormal(false)} when the code + * under test may run with flush-to-zero floating point (e.g. compiled with {@code -ffast-math}). + * {@code allowNan}/{@code allowInfinity} override these defaults where the combination is valid. + * Validation of conflicting options happens at construction time. */ public final class DoubleGenerator implements Generator { + private final Floats.DrawParams params; + private final boolean excludeMin; + private final boolean excludeMax; + private final Double min; private final Double max; private final Boolean allowNan; private final Boolean allowInfinity; - private final boolean excludeMin; - private final boolean excludeMax; + private final Boolean allowSubnormal; public DoubleGenerator( Double min, Double max, Boolean allowNan, Boolean allowInfinity, boolean excludeMin, boolean excludeMax) { - Floats.validate("doubles", min, max, allowNan, allowInfinity); + this(min, max, allowNan, allowInfinity, null, excludeMin, excludeMax); + } + + private DoubleGenerator( + Double min, + Double max, + Boolean allowNan, + Boolean allowInfinity, + Boolean allowSubnormal, + boolean excludeMin, + boolean excludeMax) { + this.params = Floats.resolve( + "doubles", 64, min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, excludeMax); this.min = min; this.max = max; this.allowNan = allowNan; this.allowInfinity = allowInfinity; + this.allowSubnormal = allowSubnormal; this.excludeMin = excludeMin; this.excludeMax = excludeMax; } @@ -39,7 +56,7 @@ public DoubleGenerator( * @return a copy with the lower bound set */ public DoubleGenerator min(double min) { - return new DoubleGenerator(min, max, allowNan, allowInfinity, excludeMin, excludeMax); + return new DoubleGenerator(min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, excludeMax); } /** @@ -47,7 +64,7 @@ public DoubleGenerator min(double min) { * @return a copy with the upper bound set */ public DoubleGenerator max(double max) { - return new DoubleGenerator(min, max, allowNan, allowInfinity, excludeMin, excludeMax); + return new DoubleGenerator(min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, excludeMax); } /** @@ -55,7 +72,7 @@ public DoubleGenerator max(double max) { * @return a copy with the NaN policy set */ public DoubleGenerator allowNan(boolean allow) { - return new DoubleGenerator(min, max, allow, allowInfinity, excludeMin, excludeMax); + return new DoubleGenerator(min, max, allow, allowInfinity, allowSubnormal, excludeMin, excludeMax); } /** @@ -63,7 +80,15 @@ public DoubleGenerator allowNan(boolean allow) { * @return a copy with the infinity policy set */ public DoubleGenerator allowInfinity(boolean allow) { - return new DoubleGenerator(min, max, allowNan, allow, excludeMin, excludeMax); + return new DoubleGenerator(min, max, allowNan, allow, allowSubnormal, excludeMin, excludeMax); + } + + /** + * @param allow whether subnormal ("denormalised") values may be generated + * @return a copy with the subnormal policy set + */ + public DoubleGenerator allowSubnormal(boolean allow) { + return new DoubleGenerator(min, max, allowNan, allowInfinity, allow, excludeMin, excludeMax); } /** @@ -71,7 +96,7 @@ public DoubleGenerator allowInfinity(boolean allow) { * @return a copy with the exclude-min policy set */ public DoubleGenerator excludeMin(boolean exclude) { - return new DoubleGenerator(min, max, allowNan, allowInfinity, exclude, excludeMax); + return new DoubleGenerator(min, max, allowNan, allowInfinity, allowSubnormal, exclude, excludeMax); } /** @@ -79,13 +104,20 @@ public DoubleGenerator excludeMin(boolean exclude) { * @return a copy with the exclude-max policy set */ public DoubleGenerator excludeMax(boolean exclude) { - return new DoubleGenerator(min, max, allowNan, allowInfinity, excludeMin, exclude); + return new DoubleGenerator(min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, exclude); } /** @hidden */ @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>( - Floats.schema(64, min, max, allowNan, allowInfinity, excludeMin, excludeMax), Cbor::asDouble); + public Double doDraw(TestCase tc) { + return tc.generateFloat( + 64, + params.min(), + params.max(), + params.allowNan(), + params.allowInfinity(), + excludeMin, + excludeMax, + params.smallestNonzeroMagnitude()); } } diff --git a/src/main/java/dev/hegel/generators/DurationGenerator.java b/shared/src/main/java/dev/hegel/generators/DurationGenerator.java similarity index 77% rename from src/main/java/dev/hegel/generators/DurationGenerator.java rename to shared/src/main/java/dev/hegel/generators/DurationGenerator.java index 6d24e61..c3b3df3 100644 --- a/src/main/java/dev/hegel/generators/DurationGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/DurationGenerator.java @@ -1,13 +1,11 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; import java.time.Duration; /** - * Generates {@link Duration} values within an inclusive {@code [min, max]} range. Always basic (one - * engine call). + * Generates {@link Duration} values within an inclusive {@code [min, max]} range. * *

    Durations are drawn as a nanosecond count, so the representable range is {@code [0, * Long.MAX_VALUE]} nanoseconds (about 292 years). The default is that whole range; narrow it with @@ -46,11 +44,7 @@ public DurationGenerator max(Duration max) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = CBORObject.NewMap() - .Add("type", "integer") - .Add("min_value", minNanos) - .Add("max_value", maxNanos); - return new BasicGenerator<>(schema, raw -> Duration.ofNanos(Cbor.asLong(raw))); + public Duration doDraw(TestCase tc) { + return Duration.ofNanos(tc.generateInteger(minNanos, maxNanos)); } } diff --git a/shared/src/main/java/dev/hegel/generators/EmailGenerator.java b/shared/src/main/java/dev/hegel/generators/EmailGenerator.java new file mode 100644 index 0000000..ba0ec87 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/EmailGenerator.java @@ -0,0 +1,17 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; + +/** + * Generates syntactically valid (RFC 5321/5322) email addresses like {@code alice@example.com}. + */ +public final class EmailGenerator implements Generator { + private final HandleCache cache = new HandleCache(); + + /** @hidden */ + @Override + public String doDraw(TestCase tc) { + return tc.generateString(cache.get(tc, TestCase::emailGenerator)); + } +} diff --git a/src/main/java/dev/hegel/generators/FilteredGenerator.java b/shared/src/main/java/dev/hegel/generators/FilteredGenerator.java similarity index 95% rename from src/main/java/dev/hegel/generators/FilteredGenerator.java rename to shared/src/main/java/dev/hegel/generators/FilteredGenerator.java index 3bbcc68..e89b158 100644 --- a/src/main/java/dev/hegel/generators/FilteredGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/FilteredGenerator.java @@ -1,8 +1,8 @@ package dev.hegel.generators; -import dev.hegel.Abi; import dev.hegel.AssumeRejected; import dev.hegel.Generator; +import dev.hegel.Label; import dev.hegel.TestCase; import java.util.function.Predicate; @@ -28,7 +28,7 @@ public FilteredGenerator(Generator source, Predicate predicate) { @Override public T doDraw(TestCase tc) { for (int attempt = 0; attempt < FILTER_RETRIES; attempt++) { - tc.startSpan(Abi.LABEL_FILTER); + tc.startSpan(Label.FILTER); boolean discard = true; try { T value = source.doDraw(tc); diff --git a/src/main/java/dev/hegel/generators/FlatMappedGenerator.java b/shared/src/main/java/dev/hegel/generators/FlatMappedGenerator.java similarity index 94% rename from src/main/java/dev/hegel/generators/FlatMappedGenerator.java rename to shared/src/main/java/dev/hegel/generators/FlatMappedGenerator.java index a155125..df56a5c 100644 --- a/src/main/java/dev/hegel/generators/FlatMappedGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/FlatMappedGenerator.java @@ -1,7 +1,7 @@ package dev.hegel.generators; -import dev.hegel.Abi; import dev.hegel.Generator; +import dev.hegel.Label; import dev.hegel.TestCase; import java.util.function.Function; @@ -24,7 +24,7 @@ public FlatMappedGenerator(Generator source, Function next = f.apply(value); diff --git a/src/main/java/dev/hegel/generators/FloatGenerator.java b/shared/src/main/java/dev/hegel/generators/FloatGenerator.java similarity index 51% rename from src/main/java/dev/hegel/generators/FloatGenerator.java rename to shared/src/main/java/dev/hegel/generators/FloatGenerator.java index bd9364b..d85d9ba 100644 --- a/src/main/java/dev/hegel/generators/FloatGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/FloatGenerator.java @@ -1,35 +1,53 @@ package dev.hegel.generators; -import dev.hegel.Cbor; import dev.hegel.Generator; import dev.hegel.Generators; +import dev.hegel.TestCase; /** - * Generates 32-bit {@code float} values with full control over bounds and special values. Always - * basic (one engine call). For 64-bit {@code double} values use {@link DoubleGenerator} ({@link - * Generators#doubles()}). + * Generates 32-bit {@code float} values with full control over bounds and special values. For + * 64-bit {@code double} values use {@link DoubleGenerator} ({@link Generators#doubles()}). * *

    Defaults mirror the engine. With no bounds, NaN and the infinities are allowed. Setting any * bound excludes NaN; setting both bounds also excludes the infinities (a single bound - * still allows the infinity on the open side). {@code allowNan}/{@code allowInfinity} override - * these defaults where the combination is valid. Bounds are {@code float}-precision and validation - * of conflicting options happens at construction time. + * still allows the infinity on the open side). Subnormal values are allowed whenever the bounds + * admit any; disable them with {@link #allowSubnormal(boolean) allowSubnormal(false)} when the code + * under test may run with flush-to-zero floating point (e.g. compiled with {@code -ffast-math}). + * {@code allowNan}/{@code allowInfinity} override these defaults where the combination is valid. + * Bounds are {@code float}-precision and validation of conflicting options happens at construction + * time. */ public final class FloatGenerator implements Generator { + private final Floats.DrawParams params; + private final boolean excludeMin; + private final boolean excludeMax; + private final Double min; private final Double max; private final Boolean allowNan; private final Boolean allowInfinity; - private final boolean excludeMin; - private final boolean excludeMax; + private final Boolean allowSubnormal; public FloatGenerator( Double min, Double max, Boolean allowNan, Boolean allowInfinity, boolean excludeMin, boolean excludeMax) { - Floats.validate("floats", min, max, allowNan, allowInfinity); + this(min, max, allowNan, allowInfinity, null, excludeMin, excludeMax); + } + + private FloatGenerator( + Double min, + Double max, + Boolean allowNan, + Boolean allowInfinity, + Boolean allowSubnormal, + boolean excludeMin, + boolean excludeMax) { + this.params = + Floats.resolve("floats", 32, min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, excludeMax); this.min = min; this.max = max; this.allowNan = allowNan; this.allowInfinity = allowInfinity; + this.allowSubnormal = allowSubnormal; this.excludeMin = excludeMin; this.excludeMax = excludeMax; } @@ -39,7 +57,7 @@ public FloatGenerator( * @return a copy with the lower bound set */ public FloatGenerator min(float min) { - return new FloatGenerator((double) min, max, allowNan, allowInfinity, excludeMin, excludeMax); + return new FloatGenerator((double) min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, excludeMax); } /** @@ -47,7 +65,7 @@ public FloatGenerator min(float min) { * @return a copy with the upper bound set */ public FloatGenerator max(float max) { - return new FloatGenerator(min, (double) max, allowNan, allowInfinity, excludeMin, excludeMax); + return new FloatGenerator(min, (double) max, allowNan, allowInfinity, allowSubnormal, excludeMin, excludeMax); } /** @@ -55,7 +73,7 @@ public FloatGenerator max(float max) { * @return a copy with the NaN policy set */ public FloatGenerator allowNan(boolean allow) { - return new FloatGenerator(min, max, allow, allowInfinity, excludeMin, excludeMax); + return new FloatGenerator(min, max, allow, allowInfinity, allowSubnormal, excludeMin, excludeMax); } /** @@ -63,7 +81,15 @@ public FloatGenerator allowNan(boolean allow) { * @return a copy with the infinity policy set */ public FloatGenerator allowInfinity(boolean allow) { - return new FloatGenerator(min, max, allowNan, allow, excludeMin, excludeMax); + return new FloatGenerator(min, max, allowNan, allow, allowSubnormal, excludeMin, excludeMax); + } + + /** + * @param allow whether subnormal ("denormalised") values may be generated + * @return a copy with the subnormal policy set + */ + public FloatGenerator allowSubnormal(boolean allow) { + return new FloatGenerator(min, max, allowNan, allowInfinity, allow, excludeMin, excludeMax); } /** @@ -71,7 +97,7 @@ public FloatGenerator allowInfinity(boolean allow) { * @return a copy with the exclude-min policy set */ public FloatGenerator excludeMin(boolean exclude) { - return new FloatGenerator(min, max, allowNan, allowInfinity, exclude, excludeMax); + return new FloatGenerator(min, max, allowNan, allowInfinity, allowSubnormal, exclude, excludeMax); } /** @@ -79,13 +105,20 @@ public FloatGenerator excludeMin(boolean exclude) { * @return a copy with the exclude-max policy set */ public FloatGenerator excludeMax(boolean exclude) { - return new FloatGenerator(min, max, allowNan, allowInfinity, excludeMin, exclude); + return new FloatGenerator(min, max, allowNan, allowInfinity, allowSubnormal, excludeMin, exclude); } /** @hidden */ @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>( - Floats.schema(32, min, max, allowNan, allowInfinity, excludeMin, excludeMax), Cbor::asFloat); + public Float doDraw(TestCase tc) { + return (float) tc.generateFloat( + 32, + params.min(), + params.max(), + params.allowNan(), + params.allowInfinity(), + excludeMin, + excludeMax, + params.smallestNonzeroMagnitude()); } } diff --git a/shared/src/main/java/dev/hegel/generators/Floats.java b/shared/src/main/java/dev/hegel/generators/Floats.java new file mode 100644 index 0000000..fca4626 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/Floats.java @@ -0,0 +1,124 @@ +package dev.hegel.generators; + +/** + * Shared validation and draw-parameter resolution for the floating-point generators ({@link + * FloatGenerator} at width 32, {@link DoubleGenerator} at width 64). Bounds are carried as {@code + * double} for both; a 32-bit generator passes f32 bounds widened losslessly to f64. + */ +final class Floats { + private Floats() {} + + /** The resolved parameters of a float draw, in the form the engine accepts. */ + record DrawParams( + double min, double max, boolean allowNan, boolean allowInfinity, double smallestNonzeroMagnitude) {} + + /** + * Validate a float generator's configuration and resolve the engine draw parameters. + * + *

    Defaults mirror the engine's canonical frontend. With no bounds, NaN and the infinities + * are allowed; setting any bound excludes NaN; setting both bounds also excludes the + * infinities. Subnormals are allowed whenever the bounds admit any. When neither NaN nor + * infinity is allowed, missing bounds are filled with the finite extremes of the target width. + */ + static DrawParams resolve( + String what, + int width, + Double min, + Double max, + Boolean allowNan, + Boolean allowInfinity, + Boolean allowSubnormal, + boolean excludeMin, + boolean excludeMax) { + if (min != null && Double.isNaN(min)) { + throw new IllegalArgumentException(what + ": min must not be NaN"); + } + if (max != null && Double.isNaN(max)) { + throw new IllegalArgumentException(what + ": max must not be NaN"); + } + boolean hasMin = min != null; + boolean hasMax = max != null; + if (hasMin && hasMax) { + if (min > max) { + throw new IllegalArgumentException(what + ": min (" + min + ") > max (" + max + ")"); + } + // A +0.0 lower bound with a -0.0 upper bound admits no values even though 0.0 == -0.0. + if (min == 0.0 + && max == 0.0 + && Double.doubleToRawLongBits(min) == 0 + && Double.doubleToRawLongBits(max) != 0) { + throw new IllegalArgumentException(what + ": no values between min 0.0 and max -0.0"); + } + if (min.doubleValue() == max.doubleValue() && (excludeMin || excludeMax)) { + throw new IllegalArgumentException( + what + ": excludeMin/excludeMax leave no values in [" + min + ", " + max + "]"); + } + } + if (excludeMin && !hasMin) { + throw new IllegalArgumentException(what + ": cannot excludeMin without a min bound"); + } + if (excludeMax && !hasMax) { + throw new IllegalArgumentException(what + ": cannot excludeMax without a max bound"); + } + // The exclude-without-bound checks above guarantee a bound exists past this point. + if (excludeMin && min == Double.POSITIVE_INFINITY) { + throw new IllegalArgumentException(what + ": excludeMin with min=+Infinity leaves no values"); + } + if (excludeMax && max == Double.NEGATIVE_INFINITY) { + throw new IllegalArgumentException(what + ": excludeMax with max=-Infinity leaves no values"); + } + + boolean an = allowNan != null ? allowNan : (!hasMin && !hasMax); + boolean ai = allowInfinity != null ? allowInfinity : (!hasMin || !hasMax); + if (an && (hasMin || hasMax)) { + throw new IllegalArgumentException(what + ": cannot allow NaN together with a bound"); + } + if (ai && hasMin && hasMax) { + throw new IllegalArgumentException(what + ": cannot allow infinity with both bounds set"); + } + + double smallestNormal = width == 32 ? Float.MIN_NORMAL : Double.MIN_NORMAL; + boolean subnormal = allowSubnormal != null ? allowSubnormal : subnormalDefault(min, max, smallestNormal); + if (subnormal) { + if (hasMin && min >= smallestNormal) { + throw new IllegalArgumentException(what + + ": allowSubnormal, but min excludes all values below the smallest positive normal " + + smallestNormal); + } + if (hasMax && max <= -smallestNormal) { + throw new IllegalArgumentException(what + + ": allowSubnormal, but max excludes all values above the smallest negative normal -" + + smallestNormal); + } + } else if (hasMin && hasMax) { + boolean containsZero = min <= 0.0 && max >= 0.0; + if (!containsZero && max < smallestNormal && min > -smallestNormal) { + throw new IllegalArgumentException( + what + ": allowSubnormal(false) leaves no values in [" + min + ", " + max + "]"); + } + } + + boolean boundedDefault = !an && !ai; + double widthMax = width == 32 ? Float.MAX_VALUE : Double.MAX_VALUE; + double lo = hasMin ? min : (boundedDefault ? -widthMax : Double.NEGATIVE_INFINITY); + double hi = hasMax ? max : (boundedDefault ? widthMax : Double.POSITIVE_INFINITY); + double smallestNonzero = subnormal ? (width == 32 ? Float.MIN_VALUE : Double.MIN_VALUE) : smallestNormal; + return new DrawParams(lo, hi, an, ai, smallestNonzero); + } + + private static boolean subnormalDefault(Double min, Double max, double smallestNormal) { + if (min != null && max != null) { + if (min.doubleValue() == max.doubleValue()) { + return -smallestNormal < min && min < smallestNormal; + } + return min < smallestNormal && max > -smallestNormal; + } + if (min != null) { + return min < smallestNormal; + } + if (max != null) { + return max > -smallestNormal; + } + return true; + } +} diff --git a/shared/src/main/java/dev/hegel/generators/HandleCache.java b/shared/src/main/java/dev/hegel/generators/HandleCache.java new file mode 100644 index 0000000..f032977 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/HandleCache.java @@ -0,0 +1,26 @@ +package dev.hegel.generators; + +import dev.hegel.StringGeneratorHandle; +import dev.hegel.TestCase; +import java.util.function.Function; + +/** + * Caches an engine string-generator handle for one generator configuration, so the alphabet or + * pattern work happens once, not per draw. + * + *

    The handle is rebuilt if a cached one was created by a different binding (a test swapped the + * engine between draws). Benign racing is fine: two threads may both build, and every built handle + * is valid and eventually freed by its own {@link StringGeneratorHandle} cleaner. + */ +final class HandleCache { + private volatile StringGeneratorHandle handle; + + StringGeneratorHandle get(TestCase tc, Function build) { + StringGeneratorHandle h = handle; + if (h == null || !tc.ownsStringGenerator(h)) { + h = build.apply(tc); + handle = h; + } + return h; + } +} diff --git a/src/main/java/dev/hegel/generators/IntegerGenerator.java b/shared/src/main/java/dev/hegel/generators/IntegerGenerator.java similarity index 75% rename from src/main/java/dev/hegel/generators/IntegerGenerator.java rename to shared/src/main/java/dev/hegel/generators/IntegerGenerator.java index 8f12011..26def0b 100644 --- a/src/main/java/dev/hegel/generators/IntegerGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/IntegerGenerator.java @@ -1,12 +1,10 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; /** - * Generates {@code int} values within an inclusive {@code [min, max]} range. Always basic (one - * engine call). + * Generates {@code int} values within an inclusive {@code [min, max]} range. * *

    The default range is the full {@code int} range; narrow it with the fluent {@link #min(int)} / * {@link #max(int)} methods. @@ -41,9 +39,7 @@ public IntegerGenerator max(int max) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = - CBORObject.NewMap().Add("type", "integer").Add("min_value", min).Add("max_value", max); - return new BasicGenerator<>(schema, Cbor::asIndex); + public Integer doDraw(TestCase tc) { + return (int) tc.generateInteger(min, max); } } diff --git a/shared/src/main/java/dev/hegel/generators/IpAddressGenerator.java b/shared/src/main/java/dev/hegel/generators/IpAddressGenerator.java new file mode 100644 index 0000000..b65c1f9 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/IpAddressGenerator.java @@ -0,0 +1,88 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.Label; +import dev.hegel.TestCase; + +/** + * Generates IP address strings. By default produces a mix of IPv4 and IPv6; restrict to one family + * with the fluent {@link #v4()} / {@link #v6()} methods. + */ +public final class IpAddressGenerator implements Generator { + private final Integer version; // null = a mix of IPv4 and IPv6 + + public IpAddressGenerator(Integer version) { + this.version = version; + } + + /** + * @return a copy that generates only IPv4 addresses + */ + public IpAddressGenerator v4() { + return new IpAddressGenerator(4); + } + + /** + * @return a copy that generates only IPv6 addresses + */ + public IpAddressGenerator v6() { + return new IpAddressGenerator(6); + } + + /** @hidden */ + @Override + public String doDraw(TestCase tc) { + if (version != null) { + return version == 4 ? formatV4(tc.generateIpv4()) : formatV6(tc.generateIpv6()); + } + tc.startSpan(Label.ONE_OF); + try { + return tc.generateInteger(0, 1) == 0 ? formatV4(tc.generateIpv4()) : formatV6(tc.generateIpv6()); + } finally { + tc.stopSpan(false); + } + } + + private static String formatV4(byte[] b) { + return (b[0] & 0xff) + "." + (b[1] & 0xff) + "." + (b[2] & 0xff) + "." + (b[3] & 0xff); + } + + /** RFC 5952 text form: lowercase hex groups with the longest zero run compressed to {@code ::}. */ + static String formatV6(byte[] b) { + int[] groups = new int[8]; + for (int i = 0; i < 8; i++) { + groups[i] = ((b[2 * i] & 0xff) << 8) | (b[2 * i + 1] & 0xff); + } + // Find the longest run of zero groups (length >= 2) to compress. + int bestStart = -1; + int bestLen = 1; + for (int i = 0; i < 8; ) { + if (groups[i] != 0) { + i++; + continue; + } + int j = i; + while (j < 8 && groups[j] == 0) { + j++; + } + if (j - i > bestLen) { + bestStart = i; + bestLen = j - i; + } + i = j; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 8; i++) { + if (i == bestStart) { + sb.append("::"); + i += bestLen - 1; + continue; + } + if (i > 0 && sb.charAt(sb.length() - 1) != ':') { + sb.append(':'); + } + sb.append(Integer.toHexString(groups[i])); + } + return sb.toString(); + } +} diff --git a/src/main/java/dev/hegel/generators/ListGenerator.java b/shared/src/main/java/dev/hegel/generators/ListGenerator.java similarity index 54% rename from src/main/java/dev/hegel/generators/ListGenerator.java rename to shared/src/main/java/dev/hegel/generators/ListGenerator.java index 9da3caf..7cea90b 100644 --- a/src/main/java/dev/hegel/generators/ListGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/ListGenerator.java @@ -1,17 +1,15 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.Label; import dev.hegel.TestCase; import java.util.ArrayList; import java.util.List; /** - * Generates lists. Basic (single engine call) when the element generator is basic; otherwise drives - * the engine's collection API element by element. The element generator opens its own spans, so no - * per-element span is needed here (matching the engine's canonical client). + * Generates lists by driving the engine's collection API element by element: the engine decides how + * many elements to produce and the shrinker can delete or reorder them. The element generator opens + * its own spans, so no per-element span is needed here (matching the engine's canonical client). * *

    The length range defaults to any size; narrow it with the fluent {@link #minSize(int)} / * {@link #maxSize(int)} methods. @@ -45,37 +43,9 @@ public ListGenerator maxSize(int maxSize) { } /** @hidden */ - @Override - public BasicGenerator> asBasic() { - BasicGenerator e = element.asBasic(); - if (e == null) { - return null; - } - CBORObject schema = CBORObject.NewMap() - .Add("type", "list") - .Add("unique", false) - .Add("elements", e.schema) - .Add("min_size", minSize); - if (maxSize != Abi.UNBOUNDED) { - schema.Add("max_size", maxSize); - } - return new BasicGenerator<>(schema, raw -> { - List rawList = Cbor.asList(raw); - List out = new ArrayList<>(rawList.size()); - for (Object o : rawList) { - out.add(e.parseRaw(o)); - } - return out; - }); - } - @Override public List doDraw(TestCase tc) { - BasicGenerator> basic = asBasic(); - if (basic != null) { - return basic.doDraw(tc); - } - tc.startSpan(Abi.LABEL_LIST); + tc.startSpan(Label.LIST); try { long id = tc.newCollection(minSize, maxSize); List out = new ArrayList<>(); diff --git a/src/main/java/dev/hegel/generators/LongGenerator.java b/shared/src/main/java/dev/hegel/generators/LongGenerator.java similarity index 75% rename from src/main/java/dev/hegel/generators/LongGenerator.java rename to shared/src/main/java/dev/hegel/generators/LongGenerator.java index b9dd6a4..894e659 100644 --- a/src/main/java/dev/hegel/generators/LongGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/LongGenerator.java @@ -1,12 +1,10 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; /** - * Generates {@code long} values within an inclusive {@code [min, max]} range. Always basic (one - * engine call). + * Generates {@code long} values within an inclusive {@code [min, max]} range. * *

    The default range is the full {@code long} range; narrow it with the fluent {@link #min(long)} * / {@link #max(long)} methods. @@ -41,9 +39,7 @@ public LongGenerator max(long max) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = - CBORObject.NewMap().Add("type", "integer").Add("min_value", min).Add("max_value", max); - return new BasicGenerator<>(schema, Cbor::asLong); + public Long doDraw(TestCase tc) { + return tc.generateInteger(min, max); } } diff --git a/src/main/java/dev/hegel/generators/MapGenerator.java b/shared/src/main/java/dev/hegel/generators/MapGenerator.java similarity index 56% rename from src/main/java/dev/hegel/generators/MapGenerator.java rename to shared/src/main/java/dev/hegel/generators/MapGenerator.java index 92e7c3b..ea8ae12 100644 --- a/src/main/java/dev/hegel/generators/MapGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/MapGenerator.java @@ -1,23 +1,17 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.Label; import dev.hegel.TestCase; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; /** - * Generates maps. Basic (one engine call) when both key and value generators are basic; otherwise - * drives the collection API, drawing a key then a value and rejecting duplicate keys. + * Generates maps by driving the engine's collection API, drawing a key then a value and rejecting + * duplicate keys. * *

    The entry-count range defaults to any size; narrow it with the fluent {@link #minSize(int)} / * {@link #maxSize(int)} methods. - * - *

    The engine's basic {@code dict} value is an array of {@code [key, value]} pairs, not a CBOR - * map. */ public final class MapGenerator implements Generator> { private final Generator keys; @@ -50,38 +44,9 @@ public MapGenerator maxSize(int maxSize) { } /** @hidden */ - @Override - public BasicGenerator> asBasic() { - BasicGenerator k = keys.asBasic(); - BasicGenerator v = values.asBasic(); - if (k == null || v == null) { - return null; - } - CBORObject schema = CBORObject.NewMap() - .Add("type", "dict") - .Add("keys", k.schema) - .Add("values", v.schema) - .Add("min_size", minSize); - if (maxSize != Abi.UNBOUNDED) { - schema.Add("max_size", maxSize); - } - return new BasicGenerator<>(schema, raw -> { - Map out = new LinkedHashMap<>(); - for (Object pairRaw : Cbor.asList(raw)) { - List pair = Cbor.asList(pairRaw); - out.put(k.parseRaw(pair.get(0)), v.parseRaw(pair.get(1))); - } - return out; - }); - } - @Override public Map doDraw(TestCase tc) { - BasicGenerator> basic = asBasic(); - if (basic != null) { - return basic.doDraw(tc); - } - tc.startSpan(Abi.LABEL_MAP); + tc.startSpan(Label.MAP); try { long id = tc.newCollection(minSize, maxSize); Map out = new LinkedHashMap<>(); diff --git a/shared/src/main/java/dev/hegel/generators/MappedGenerator.java b/shared/src/main/java/dev/hegel/generators/MappedGenerator.java new file mode 100644 index 0000000..eaba2fe --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/MappedGenerator.java @@ -0,0 +1,33 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.Label; +import dev.hegel.TestCase; +import java.util.function.Function; + +/** + * Result of {@link Generator#map}. Draws from the source and applies {@code f}, bracketing the pair + * in a {@code map} span so the shrinker treats them as one unit. + * + * @param the source value type + * @param the mapped value type + */ +public final class MappedGenerator implements Generator { + private final Generator source; + private final Function f; + + public MappedGenerator(Generator source, Function f) { + this.source = source; + this.f = f; + } + + @Override + public U doDraw(TestCase tc) { + tc.startSpan(Label.MAPPED); + try { + return f.apply(source.doDraw(tc)); + } finally { + tc.stopSpan(false); + } + } +} diff --git a/shared/src/main/java/dev/hegel/generators/OneOfGenerator.java b/shared/src/main/java/dev/hegel/generators/OneOfGenerator.java new file mode 100644 index 0000000..c67196d --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/OneOfGenerator.java @@ -0,0 +1,33 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.Label; +import dev.hegel.TestCase; +import java.util.List; + +/** + * Chooses among alternative generators of the same type: an index is drawn and the selected + * alternative is generated inside a ONE_OF span, so the shrinker can swap which branch is taken. + */ +public final class OneOfGenerator implements Generator { + private final List> options; + + public OneOfGenerator(List> options) { + if (options.isEmpty()) { + throw new IllegalArgumentException("oneOf requires at least one generator"); + } + this.options = List.copyOf(options); + } + + /** @hidden */ + @Override + public T doDraw(TestCase tc) { + tc.startSpan(Label.ONE_OF); + try { + int index = (int) tc.generateInteger(0, options.size() - 1); + return options.get(index).doDraw(tc); + } finally { + tc.stopSpan(false); + } + } +} diff --git a/src/main/java/dev/hegel/generators/RecordGenerator.java b/shared/src/main/java/dev/hegel/generators/RecordGenerator.java similarity index 97% rename from src/main/java/dev/hegel/generators/RecordGenerator.java rename to shared/src/main/java/dev/hegel/generators/RecordGenerator.java index fe24237..8bc0177 100644 --- a/src/main/java/dev/hegel/generators/RecordGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/RecordGenerator.java @@ -1,8 +1,8 @@ package dev.hegel.generators; -import dev.hegel.Abi; import dev.hegel.Generator; import dev.hegel.HegelException; +import dev.hegel.Label; import dev.hegel.TestCase; import java.lang.reflect.RecordComponent; import java.util.HashMap; @@ -54,7 +54,7 @@ public T doDraw(TestCase tc) { RecordComponent[] components = type.getRecordComponents(); Object[] values = new Object[components.length]; Class[] paramTypes = new Class[components.length]; - tc.startSpan(Abi.LABEL_FIXED_DICT); + tc.startSpan(Label.FIXED_DICT); try { for (int i = 0; i < components.length; i++) { RecordComponent rc = components[i]; diff --git a/shared/src/main/java/dev/hegel/generators/RegexGenerator.java b/shared/src/main/java/dev/hegel/generators/RegexGenerator.java new file mode 100644 index 0000000..c63d893 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/RegexGenerator.java @@ -0,0 +1,36 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; + +/** + * Generates strings matching a (Python-compatible) regular expression. By default the entire + * string matches the pattern; use {@link #fullmatch(boolean) fullmatch(false)} to generate strings + * that merely contain a match. The pattern is validated by the engine when the first value is + * drawn. + */ +public final class RegexGenerator implements Generator { + private final String pattern; + private final boolean fullmatch; + private final HandleCache cache = new HandleCache(); + + public RegexGenerator(String pattern, boolean fullmatch) { + this.pattern = pattern; + this.fullmatch = fullmatch; + } + + /** + * @param fullmatch whether the entire string must match the pattern (the default), or merely + * contain a match somewhere within it + * @return a copy with the fullmatch behaviour set + */ + public RegexGenerator fullmatch(boolean fullmatch) { + return new RegexGenerator(pattern, fullmatch); + } + + /** @hidden */ + @Override + public String doDraw(TestCase tc) { + return tc.generateString(cache.get(tc, t -> t.regexGenerator(pattern, fullmatch, null))); + } +} diff --git a/src/main/java/dev/hegel/generators/SampledFromGenerator.java b/shared/src/main/java/dev/hegel/generators/SampledFromGenerator.java similarity index 58% rename from src/main/java/dev/hegel/generators/SampledFromGenerator.java rename to shared/src/main/java/dev/hegel/generators/SampledFromGenerator.java index aa6dcb6..6a678fd 100644 --- a/src/main/java/dev/hegel/generators/SampledFromGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/SampledFromGenerator.java @@ -1,13 +1,12 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; import java.util.List; /** * Picks one of a fixed, non-empty list of values (the first is the simplest for shrinking). Drawn - * as an index into the list, so always basic (one engine call). + * as an index into the list. * * @param the value type */ @@ -23,9 +22,7 @@ public SampledFromGenerator(List values) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = - CBORObject.NewMap().Add("type", "integer").Add("min_value", 0).Add("max_value", values.size() - 1); - return new BasicGenerator<>(schema, raw -> values.get(Cbor.asIndex(raw))); + public T doDraw(TestCase tc) { + return values.get((int) tc.generateInteger(0, values.size() - 1)); } } diff --git a/src/main/java/dev/hegel/generators/SetGenerator.java b/shared/src/main/java/dev/hegel/generators/SetGenerator.java similarity index 57% rename from src/main/java/dev/hegel/generators/SetGenerator.java rename to shared/src/main/java/dev/hegel/generators/SetGenerator.java index f936193..412f88d 100644 --- a/src/main/java/dev/hegel/generators/SetGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/SetGenerator.java @@ -1,17 +1,14 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.Label; import dev.hegel.TestCase; import java.util.LinkedHashSet; import java.util.Set; /** - * Generates sets of distinct elements. Basic (one engine call, {@code unique:true} schema) when the - * element generator is basic; otherwise drives the collection API and rejects duplicates so the - * engine keeps producing until the set reaches its size. + * Generates sets of distinct elements by driving the engine's collection API and rejecting + * duplicates, so the engine keeps producing until the set reaches its size. * *

    The size range defaults to any size; narrow it with the fluent {@link #minSize(int)} / {@link * #maxSize(int)} methods. @@ -45,36 +42,9 @@ public SetGenerator maxSize(int maxSize) { } /** @hidden */ - @Override - public BasicGenerator> asBasic() { - BasicGenerator e = element.asBasic(); - if (e == null) { - return null; - } - CBORObject schema = CBORObject.NewMap() - .Add("type", "list") - .Add("unique", true) - .Add("elements", e.schema) - .Add("min_size", minSize); - if (maxSize != Abi.UNBOUNDED) { - schema.Add("max_size", maxSize); - } - return new BasicGenerator<>(schema, raw -> { - Set out = new LinkedHashSet<>(); - for (Object o : Cbor.asList(raw)) { - out.add(e.parseRaw(o)); - } - return out; - }); - } - @Override public Set doDraw(TestCase tc) { - BasicGenerator> basic = asBasic(); - if (basic != null) { - return basic.doDraw(tc); - } - tc.startSpan(Abi.LABEL_SET); + tc.startSpan(Label.SET); try { long id = tc.newCollection(minSize, maxSize); Set out = new LinkedHashSet<>(); diff --git a/shared/src/main/java/dev/hegel/generators/Sizes.java b/shared/src/main/java/dev/hegel/generators/Sizes.java new file mode 100644 index 0000000..012c3eb --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/Sizes.java @@ -0,0 +1,29 @@ +package dev.hegel.generators; + +import dev.hegel.lowlevel.Abi; + +/** Validation and defaulting helpers for size bounds. */ +final class Sizes { + private Sizes() {} + + static void validate(long minSize, long maxSize, String what) { + if (minSize < 0) { + throw new IllegalArgumentException(what + ": minSize must be >= 0, got " + minSize); + } + if (maxSize != Abi.UNBOUNDED && maxSize < minSize) { + throw new IllegalArgumentException( + what + ": maxSize (" + maxSize + ") must be >= minSize (" + minSize + ")"); + } + } + + /** + * The effective maximum for a length-bounded draw: the explicit {@code maxSize} when one was + * set, otherwise {@code defaultMax} (shifted up when {@code minSize} exceeds it). + */ + static long resolveMax(long minSize, long maxSize, long defaultMax) { + if (maxSize != Abi.UNBOUNDED) { + return maxSize; + } + return minSize > defaultMax ? minSize + defaultMax : defaultMax; + } +} diff --git a/src/main/java/dev/hegel/generators/TextGenerator.java b/shared/src/main/java/dev/hegel/generators/TextGenerator.java similarity index 75% rename from src/main/java/dev/hegel/generators/TextGenerator.java rename to shared/src/main/java/dev/hegel/generators/TextGenerator.java index 6c0fcaa..0f9d776 100644 --- a/src/main/java/dev/hegel/generators/TextGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/TextGenerator.java @@ -1,20 +1,25 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; +import dev.hegel.lowlevel.Abi; import java.util.ArrayList; import java.util.List; /** - * Generates strings with fine-grained control over length and character selection. Always basic. + * Generates strings with fine-grained control over length and character selection. * *

    Surrogate codepoints (Unicode category {@code Cs}) are excluded by default so generated * strings round-trip cleanly through Java; request specific categories to override the default * exclusion. + * + *

    Lengths default to {@code [0, 100]} characters (or {@code [minSize, minSize + 100]} for a + * larger minimum); set an explicit {@link #maxSize(int)} for longer strings. */ public final class TextGenerator implements Generator { + /** The default length cap when no explicit {@code maxSize} is set. */ + static final long DEFAULT_MAX_SIZE = 100; + private final long minSize; private final long maxSize; private final Integer minCodepoint; @@ -23,6 +28,7 @@ public final class TextGenerator implements Generator { private final List excludeCategories; private final String includeChars; private final String excludeChars; + private final HandleCache cache = new HandleCache(); public TextGenerator( long minSize, @@ -38,12 +44,24 @@ public TextGenerator( this.maxSize = maxSize; this.minCodepoint = minCodepoint; this.maxCodepoint = maxCodepoint; - this.categories = categories; + this.categories = validateCategories(categories); this.excludeCategories = excludeCategories; this.includeChars = includeChars; this.excludeChars = excludeChars; } + private static List validateCategories(List categories) { + if (categories != null) { + for (String c : categories) { + if (c.equals("Cs") || c.equals("C")) { + throw new IllegalArgumentException( + "text: category \"" + c + "\" includes surrogate codepoints, unsupported"); + } + } + } + return categories; + } + /** * @param minSize the minimum codepoint length * @return a copy with the minimum size set @@ -137,44 +155,30 @@ public TextGenerator excludeCharacters(String chars) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = CBORObject.NewMap().Add("type", "string").Add("min_size", minSize); - if (maxSize != Abi.UNBOUNDED) { - schema.Add("max_size", maxSize); - } - if (minCodepoint != null) { - schema.Add("min_codepoint", minCodepoint); - } - if (maxCodepoint != null) { - schema.Add("max_codepoint", maxCodepoint); - } + public String doDraw(TestCase tc) { + return tc.generateString(cache.get(tc, this::buildHandle)); + } + + private dev.hegel.StringGeneratorHandle buildHandle(TestCase tc) { + List exclude; if (categories != null) { - CBORObject arr = CBORObject.NewArray(); - for (String c : categories) { - if (c.equals("Cs") || c.equals("C")) { - throw new IllegalArgumentException( - "text: category \"" + c + "\" includes surrogate codepoints, unsupported"); - } - arr.Add(c); - } - schema.Add("categories", arr); + exclude = null; } else { - List excl = new ArrayList<>(excludeCategories == null ? List.of() : excludeCategories); - if (!excl.contains("Cs")) { - excl.add("Cs"); + // Surrogates are excluded by default so drawn strings are valid Java strings. + exclude = new ArrayList<>(excludeCategories == null ? List.of() : excludeCategories); + if (!exclude.contains("Cs")) { + exclude.add("Cs"); } - CBORObject arr = CBORObject.NewArray(); - for (String c : excl) { - arr.Add(c); - } - schema.Add("exclude_categories", arr); - } - if (includeChars != null) { - schema.Add("include_characters", includeChars); } - if (excludeChars != null) { - schema.Add("exclude_characters", excludeChars); - } - return new BasicGenerator<>(schema, Cbor::asString); + return tc.textGenerator( + minSize, + Sizes.resolveMax(minSize, maxSize, DEFAULT_MAX_SIZE), + null, + minCodepoint == null ? 0 : minCodepoint, + maxCodepoint == null ? Abi.NO_MAX_CODEPOINT : maxCodepoint, + categories, + exclude, + includeChars, + excludeChars); } } diff --git a/shared/src/main/java/dev/hegel/generators/TimeGenerator.java b/shared/src/main/java/dev/hegel/generators/TimeGenerator.java new file mode 100644 index 0000000..145cc33 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/TimeGenerator.java @@ -0,0 +1,51 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; +import java.time.LocalTime; + +/** + * Generates {@link LocalTime} values within an inclusive {@code [min, max]} range, at nanosecond + * resolution. + * + *

    The default range is the whole day; narrow it with the fluent {@link #min(LocalTime)} / + * {@link #max(LocalTime)} methods. Values shrink toward the lower bound. + */ +public final class TimeGenerator implements Generator { + private final LocalTime min; + private final LocalTime max; + + public TimeGenerator() { + this(LocalTime.MIN, LocalTime.MAX); + } + + public TimeGenerator(LocalTime min, LocalTime max) { + if (min.isAfter(max)) { + throw new IllegalArgumentException("times: min (" + min + ") > max (" + max + ")"); + } + this.min = min; + this.max = max; + } + + /** + * @param min the inclusive lower bound + * @return a copy with the lower bound set + */ + public TimeGenerator min(LocalTime min) { + return new TimeGenerator(min, max); + } + + /** + * @param max the inclusive upper bound + * @return a copy with the upper bound set + */ + public TimeGenerator max(LocalTime max) { + return new TimeGenerator(min, max); + } + + /** @hidden */ + @Override + public LocalTime doDraw(TestCase tc) { + return tc.generateTime(min, max); + } +} diff --git a/shared/src/main/java/dev/hegel/generators/TupleGenerator.java b/shared/src/main/java/dev/hegel/generators/TupleGenerator.java new file mode 100644 index 0000000..0c67b72 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/TupleGenerator.java @@ -0,0 +1,40 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.Label; +import dev.hegel.TestCase; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Generates fixed-length heterogeneous tuples. The element values are drawn in order inside a + * TUPLE span and handed to an {@code assembler} that packs them into the user-facing type {@code + * T} (a {@code TupleN} record, or the raw {@code List} for the variadic factory). + * + * @param the assembled tuple type + */ +public final class TupleGenerator implements Generator { + private final List> elements; + private final Function, T> assembler; + + public TupleGenerator(List> elements, Function, T> assembler) { + this.elements = List.copyOf(elements); + this.assembler = assembler; + } + + /** @hidden */ + @Override + public T doDraw(TestCase tc) { + tc.startSpan(Label.TUPLE); + try { + List out = new ArrayList<>(elements.size()); + for (Generator g : elements) { + out.add(g.doDraw(tc)); + } + return assembler.apply(out); + } finally { + tc.stopSpan(false); + } + } +} diff --git a/shared/src/main/java/dev/hegel/generators/UrlGenerator.java b/shared/src/main/java/dev/hegel/generators/UrlGenerator.java new file mode 100644 index 0000000..4a89075 --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/UrlGenerator.java @@ -0,0 +1,17 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; + +/** + * Generates syntactically valid (RFC 3986) {@code http}/{@code https} URLs. + */ +public final class UrlGenerator implements Generator { + private final HandleCache cache = new HandleCache(); + + /** @hidden */ + @Override + public String doDraw(TestCase tc) { + return tc.generateString(cache.get(tc, TestCase::urlGenerator)); + } +} diff --git a/shared/src/main/java/dev/hegel/generators/UuidGenerator.java b/shared/src/main/java/dev/hegel/generators/UuidGenerator.java new file mode 100644 index 0000000..c9361ca --- /dev/null +++ b/shared/src/main/java/dev/hegel/generators/UuidGenerator.java @@ -0,0 +1,44 @@ +package dev.hegel.generators; + +import dev.hegel.Generator; +import dev.hegel.TestCase; +import java.util.UUID; + +/** + * Generates {@link UUID} values. + * + *

    By default generates UUIDs of any version (uniform 128 bits, never the nil UUID); use {@link + * #version(int)} to restrict to a specific RFC 4122 version (1–5). + */ +public final class UuidGenerator implements Generator { + private final Integer version; + + public UuidGenerator() { + this((Integer) null); + } + + private UuidGenerator(Integer version) { + this.version = validateVersion(version); + } + + /** + * @param version the UUID version to generate; must be an RFC 4122 version in {@code [1, 5]} + * @return a copy pinned to the requested version + */ + public UuidGenerator version(int version) { + return new UuidGenerator(version); + } + + private static Integer validateVersion(Integer version) { + if (version != null && (version < 1 || version > 5)) { + throw new IllegalArgumentException("uuids: version must be in [1, 5]"); + } + return version; + } + + /** @hidden */ + @Override + public UUID doDraw(TestCase tc) { + return tc.generateUuid(version); + } +} diff --git a/src/main/java/dev/hegel/generators/ZoneOffsetGenerator.java b/shared/src/main/java/dev/hegel/generators/ZoneOffsetGenerator.java similarity index 76% rename from src/main/java/dev/hegel/generators/ZoneOffsetGenerator.java rename to shared/src/main/java/dev/hegel/generators/ZoneOffsetGenerator.java index e982e71..6b72589 100644 --- a/src/main/java/dev/hegel/generators/ZoneOffsetGenerator.java +++ b/shared/src/main/java/dev/hegel/generators/ZoneOffsetGenerator.java @@ -1,13 +1,12 @@ package dev.hegel.generators; -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; import dev.hegel.Generator; +import dev.hegel.TestCase; import java.time.ZoneOffset; /** * Generates {@link ZoneOffset} values (fixed UTC offsets) within an inclusive {@code [min, max]} - * range. Always basic (one engine call). + * range. * *

    Offsets are drawn at one-second granularity. The default range is the whole legal {@code * ZoneOffset} span ({@code -18:00} to {@code +18:00}); narrow it with the fluent {@link @@ -43,11 +42,7 @@ public ZoneOffsetGenerator max(ZoneOffset max) { /** @hidden */ @Override - public BasicGenerator asBasic() { - CBORObject schema = CBORObject.NewMap() - .Add("type", "integer") - .Add("min_value", minSeconds) - .Add("max_value", maxSeconds); - return new BasicGenerator<>(schema, raw -> ZoneOffset.ofTotalSeconds(Cbor.asIndex(raw))); + public ZoneOffset doDraw(TestCase tc) { + return ZoneOffset.ofTotalSeconds((int) tc.generateInteger(minSeconds, maxSeconds)); } } diff --git a/shared/src/main/java/dev/hegel/package-info.java b/shared/src/main/java/dev/hegel/package-info.java new file mode 100644 index 0000000..08a116f --- /dev/null +++ b/shared/src/main/java/dev/hegel/package-info.java @@ -0,0 +1,274 @@ +/** + * Property-based testing for Java, powered by the Hegel engine. + * + *

    Instead of writing tests with hand-picked example inputs, you describe a property that + * should hold for all inputs and let Hegel generate inputs to try to falsify it. When it finds a + * failing input it automatically shrinks it to a minimal counterexample. + * + *

    Hegel requires Java 22+ (it uses the Foreign Function & Memory API) and + * {@code --enable-native-access=ALL-UNNAMED} on the test JVM. The native engine, {@code libhegel}, + * is bundled inside the jar for every supported platform and loaded automatically — nothing else to + * install. + * + *

    Your first test

    + * + *

    Use JUnit 5 as the runner. Annotate a method with {@link dev.hegel.HegelTest @HegelTest} and + * give it a {@link dev.hegel.TestCase} parameter: + * + *

    {@code
    + * import static dev.hegel.Generators.integers;
    + * import static org.junit.jupiter.api.Assertions.assertEquals;
    + *
    + * import dev.hegel.HegelTest;
    + * import dev.hegel.TestCase;
    + *
    + * class FirstTest {
    + *   @HegelTest
    + *   void integerSelfEquality(TestCase tc) {
    + *     int n = tc.draw(integers());
    + *     assertEquals(n, n); // an integer always equals itself
    + *   }
    + * }
    + * }
    + * + *

    {@code @HegelTest} runs the method many times (100 by default). Each run receives a + * {@link dev.hegel.TestCase}, whose {@link dev.hegel.TestCase#draw(dev.hegel.Generator) draw} method + * produces a value from a generator. + * + *

    When you need a setting that can't be a compile-time constant, or want to run a property + * outside a JUnit method, drive it programmatically with + * {@link dev.hegel.Hegel#test(java.util.function.Consumer)} — the body comes first, with optional + * {@link dev.hegel.Settings}: + * + *

    {@code
    + * import static dev.hegel.Generators.integers;
    + * import static org.junit.jupiter.api.Assertions.assertEquals;
    + *
    + * import dev.hegel.Hegel;
    + * import org.junit.jupiter.api.Test;
    + *
    + * class CommutativityTest {
    + *   @Test
    + *   void additionCommutes() {
    + *     Hegel.test(tc -> {
    + *       int x = tc.draw(integers());
    + *       int y = tc.draw(integers());
    + *       assertEquals(x + y, y + x);
    + *     });
    + *   }
    + * }
    + * }
    + * + *

    Understanding test output

    + * + *

    When a property fails, Hegel replays the minimal counterexample and prints each top-level + * {@code draw} as an assignment: + * + *

    {@code
    + * draw_1 = 50;
    + * }
    + * + *

    Pass a label — {@code tc.draw(integers(), "n")} — to name the variable instead: + * + *

    {@code
    + * n = 50;
    + * }
    + * + *

    This report is produced by the run's {@link dev.hegel.Reporter}; the default prints to + * {@code System.err}. Pass your own to {@link dev.hegel.Hegel#test(java.util.function.Consumer, + * dev.hegel.Settings, dev.hegel.Reporter)} to route it elsewhere, or {@link + * dev.hegel.Reporter#silent()} to suppress it. + * + *

    Generators

    + * + *

    {@link dev.hegel.Generators} provides a rich set of generators. Primitives include + * {@code integers}, {@code longs}, {@code floats} (32-bit) and {@code doubles} (64-bit), + * {@code booleans}, {@code text}, and {@code binary}; collections include {@code lists}, + * {@code sets}, and {@code maps}; and there are {@code tuples}, {@code oneOf}, {@code optional}, + * {@code sampledFrom}, {@code just}, {@code durations} ({@code java.time.Duration}), and the temporal + * generators {@code dates}, {@code times}, and {@code datetimes} (which produce + * {@code java.time.LocalDate}/{@code LocalTime}/{@code LocalDateTime}), plus format generators + * ({@code emails}, {@code urls}, {@code ipAddresses}, {@code uuids}, {@code fromRegex}, …). + * + *

    For zone-aware datetimes, attach a timezone to a {@code datetimes()} generator: + * + *

      + *
    • {@link dev.hegel.generators.DateTimeGenerator#timezones timezones}: + * {@code datetimes().timezones(zoneIds())} produces DST-aware {@code java.time.ZonedDateTime} + * values over the full range of zones the JVM supports (see + * {@link dev.hegel.Generators#zoneIds()}); pin one with + * {@code datetimes().timezones(just(ZoneId.of("Europe/London")))}. + *
    • {@link dev.hegel.generators.DateTimeGenerator#offsets offsets}: + * {@code datetimes().offsets(zoneOffsets())} produces fixed-offset + * {@code java.time.OffsetDateTime} values (see {@link dev.hegel.Generators#zoneOffsets()}). + *
    + * + *

    The bound- and size-bearing generators are fluent builders that are the generator: + * + *

    {@code
    + * tc.draw(integers().min(0).max(100));       // bounded ints
    + * tc.draw(text().minSize(1).maxSize(10));    // short strings
    + * tc.draw(doubles().min(0).max(1));          // a probability (64-bit)
    + * tc.draw(floats().min(0).max(1));           // a 32-bit float in [0, 1]
    + * tc.draw(lists(integers()).minSize(1).maxSize(5));          // 1–5 element lists
    + * }
    + * + *

    Combinators

    + * + *

    Build new generators from existing ones (see {@link dev.hegel.Generator}): + * + *

      + *
    • {@link dev.hegel.Generator#map map} transforms each value (and keeps the efficient + * single-draw path when possible): + *
      {@code
      + * Generator evens = integers().min(0).max(50).map(x -> x * 2);
      + * }
      + *
    • {@link dev.hegel.Generator#filter filter} keeps values matching a predicate (prefer + * constraining over filtering when you can): + *
      {@code
      + * Generator big = integers().filter(x -> x > 1000);
      + * }
      + *
    • {@link dev.hegel.Generator#flatMap flatMap} makes one draw depend on another: + *
      {@code
      + * Generator> sized = integers().min(0).max(10).flatMap(n -> lists(booleans()).minSize(n).maxSize(n));
      + * }
      + *
    • {@link dev.hegel.Generators#composite composite} builds a value imperatively from several + * draws: + *
      {@code
      + * Generator pair = Generators.composite(tc -> new int[] {
      + *     tc.draw(integers()), tc.draw(integers())
      + * });
      + * }
      + *
    + * + *

    Recursive generators

    + * + *

    {@link dev.hegel.Generators#deferred() deferred} creates a forward reference so a generator can + * refer to itself, enabling self-recursive (and mutually recursive) data such as trees: + * + *

    {@code
    + * record Tree(Integer leaf, Tree left, Tree right) {} // leaf != null XOR children != null
    + *
    + * Deferred tree = Generators.deferred();
    + * Generator leaf = integers().map(n -> new Tree(n, null, null));
    + * Generator branch =
    + *     tuples(tree, tree).map(t -> new Tree(null, t.value1(), t.value2()));
    + * tree.set(oneOf(leaf, branch)); // wire up the self-reference
    + * Tree t = tc.draw(tree);
    + * }
    + * + *

    The engine's size control keeps generated structures finite. Drawing before + * {@link dev.hegel.generators.Deferred#set set} is called fails. + * + *

    Control functions

    + * + *

    Inside a test body you can steer the engine via {@link dev.hegel.TestCase}: + * + *

      + *
    • {@link dev.hegel.TestCase#assume(boolean) assume} discards the current input if a + * precondition does not hold. + *
    • {@link dev.hegel.TestCase#note(String) note} records a message shown only on the final + * replay of a failing case. + *
    • {@link dev.hegel.TestCase#target(double, String) target} reports a score so the search can + * hill-climb toward interesting inputs. + *
    • {@link dev.hegel.TestCase#isFinal() isFinal} tells whether this run of the body is the + * final replay of a minimal counterexample, where expensive diagnostics are worth doing. + *
    + * + *
    {@code
    + * @HegelTest
    + * void divisionRoundTrips(TestCase tc) {
    + *   int x = tc.draw(integers().min(1).max(1000));
    + *   int y = tc.draw(integers().min(1).max(1000));
    + *   tc.assume(y != 0);
    + *   tc.note("testing " + x + " * " + y + " / " + y);
    + *   assertEquals(x, (x * y) / y);
    + * }
    + * }
    + * + *

    Settings

    + * + *

    Configure a run by passing a {@link dev.hegel.Settings} value (built with + * {@code new Settings()} and the fluent setters) to + * {@link dev.hegel.Hegel#test(java.util.function.Consumer, dev.hegel.Settings)}, or with attributes + * on {@link dev.hegel.HegelTest @HegelTest}: + * + *

    {@code
    + * Hegel.test(
    + *     tc -> {
    + *       // your property here
    + *     },
    + *     new Settings()
    + *         .testCases(500)    // run more inputs
    + *         .seed(42));        // reproducible run
    + *
    + * @HegelTest(testCases = 1000, seed = 42)
    + * void thorough(TestCase tc) {
    + *   // your property here
    + * }
    + * }
    + * + *

    Other settings include {@code derandomize}, + * {@link dev.hegel.Settings#database(dev.hegel.Database) database}, {@code suppressHealthCheck}, + * {@code verbosity}, {@code mode}, and {@link dev.hegel.Settings#phases(dev.hegel.Phase...) phases}. + * Whatever a test leaves unset is resolved by the engine from its settings profile — a {@code + * hegel.toml} in the working directory or an ancestor, or the shipped {@code ci} profile, which + * makes runs deterministic and disables the example database when a CI server is detected — and from + * the {@code HEGEL_TEST_CASES}, {@code HEGEL_SEED}, {@code HEGEL_DERANDOMIZE}, {@code HEGEL_DATABASE} + * and {@code HEGEL_PRINT_BLOB} environment variables, which win over the profile. + * If a health check fires — for example, your generators reject almost every input — Hegel aborts + * the run and throws {@link dev.hegel.HealthCheckFailure} (distinct from a property's own failure); + * pass the relevant {@link dev.hegel.HealthCheck} to {@code suppressHealthCheck} if the behaviour is + * intentional. + * + *

    Using Hegel as a library

    + * + *

    Frontends for other JVM languages, or custom test runners, drive Hegel through + * {@link dev.hegel.Hegel#run(java.util.function.Consumer, dev.hegel.Settings, dev.hegel.Reporter)} + * rather than {@code Hegel.test}. It never throws for a property outcome; it returns a + * {@link dev.hegel.RunReport} with the verdict ({@link dev.hegel.RunStatus}), the + * {@link dev.hegel.RunStatistics case counts}, the engine's message for an errored run, and one + * {@link dev.hegel.Failure} per distinct counterexample carrying the exception the body threw, the + * labelled top-level draws of the minimal example as Java values, the notes, and the reproduce blob. + * A {@link dev.hegel.Reporter} receives the same information as callbacks while the run proceeds, + * which is how a frontend owns its output instead of sharing {@code System.err}: + * + *

    {@code
    + * RunReport report = Hegel.run(body, new Settings().testCases(200), Reporter.silent());
    + * if (report.status() == RunStatus.FAILED) {
    + *   Failure f = report.failures().get(0);
    + *   f.draws();       // {"xs": [0, 0]}
    + *   f.exception();   // the body's own throwable
    + *   f.reproduceBlob();
    + * }
    + * report.throwIfFailed(); // Hegel.test's behaviour, when wanted
    + * }
    + * + *

    The test body is any {@code Consumer}: returning normally passes the case, throwing + * anything fails it, and nothing is JUnit-specific. Hegel distinguishes bugs by the exception's + * type and the first stack frame outside Hegel, the JDK, and JUnit; a frontend lists its own + * packages in {@link dev.hegel.Settings#infrastructurePackages(String...)} so they are skipped + * too. Custom composite generators implement {@link dev.hegel.Generator} and enclose their draws + * in {@link dev.hegel.TestCase#span(long, java.util.function.Supplier) tc.span} with a + * {@link dev.hegel.Label} so the engine shrinks the structure as a unit. + * + *

    Deriving generators from types

    + * + *

    Hegel can build a generator for a record, enum, or supported scalar/collection type by + * reflection via {@link dev.hegel.Generators#forType(Class) forType} and + * {@link dev.hegel.Generators#records(Class) records}: + * + *

    {@code
    + * record Point(int x, int y) {}
    + * enum Color { RED, GREEN, BLUE }
    + *
    + * @HegelTest
    + * void derived(TestCase tc) {
    + *   Point p = tc.draw(Generators.forType(Point.class));
    + *   Color c = tc.draw(Generators.forType(Color.class));
    + *   // Override a single component:
    + *   Point bounded = tc.draw(Generators.records(Point.class).with("x", integers().min(0).max(9)));
    + * }
    + * }
    + */ +package dev.hegel; diff --git a/shared/src/test/java/dev/hegel/BindingErrorPathsTest.java b/shared/src/test/java/dev/hegel/BindingErrorPathsTest.java new file mode 100644 index 0000000..797960a --- /dev/null +++ b/shared/src/test/java/dev/hegel/BindingErrorPathsTest.java @@ -0,0 +1,303 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +/** Covers {@link LiveDataSource} return-code translation and the abort short-circuit. */ +class BindingErrorPathsTest { + private static final LocalDate DATE = LocalDate.of(2000, 1, 1); + private static final LocalTime TIME = LocalTime.NOON; + private static final LocalDateTime DATETIME = LocalDateTime.of(DATE, TIME); + + private LiveDataSource source(FakeLibhegel fake) { + return new LiveDataSource(fake, FakeLibhegel.TC); + } + + @Test + void okPathsReturnTheEngineValues() { + FakeLibhegel fake = new FakeLibhegel(); + fake.booleanValue = true; + fake.integerValue = 9L; + fake.floatValue = 2.5; + fake.stringValue = "drawn"; + LiveDataSource ds = source(fake); + assertTrue(ds.generateBoolean(0.5)); + assertEquals(9, ds.generateInteger(0, 10)); + assertEquals(2.5, ds.generateFloat(64, 0, 10, false, false, false, false, Double.MIN_VALUE)); + assertArrayEquals(new byte[] {1, 2}, ds.generateBytes(0, 4)); + assertEquals("drawn", ds.generateString(ds.emailGenerator())); + assertEquals(DATE, ds.generateDate(DATE, DATE)); + assertEquals(TIME, ds.generateTime(TIME, TIME)); + assertEquals(DATETIME, ds.generateDatetime(DATETIME, DATETIME)); + assertEquals(new UUID(0, 1), ds.generateUuid(null)); + assertArrayEquals(new byte[] {127, 0, 0, 1}, ds.generateIpv4()); + assertArrayEquals(new byte[16], ds.generateIpv6()); + assertEquals(7, ds.newCollection(0, 5)); + assertFalse(ds.collectionMore(7)); + ds.collectionReject(7, "dup"); + ds.startSpan(Label.LIST); + ds.stopSpan(false); + ds.target(1.0, "l"); + assertEquals(3, ds.newPool()); + assertEquals(0, ds.poolAdd(3)); + assertEquals(0, ds.poolGenerate(3, true)); + fake.stateMachineConcurrency = 3; + DataSource.StateMachine sm = + ds.newStateMachine(List.of("r"), new long[] {0}, null, List.of("i"), new boolean[] {false}, 1, 4, 50); + assertEquals(5, sm.id()); + assertEquals(3, sm.concurrency()); + assertEquals(1, fake.stateMachineMinConcurrency); + assertEquals(4, fake.stateMachineMaxConcurrency); + assertEquals(Abi.STATE_MACHINE_DONE, ds.stateMachineNextGroup(5)); + assertEquals(Abi.STATE_MACHINE_DONE, ds.stateMachineNextRule(5, 2)); + ds.stateMachineRuleRejected(5, 2); + assertEquals(List.of(2L), fake.nextRuleWorkers); + assertEquals(List.of(2L), fake.rejectedWorkers); + assertTrue(ds.stateMachineShouldCheckInvariant(5, 0)); + ds.stateMachineFree(5); + assertEquals(1, fake.freedStateMachines); + } + + @Test + void clonesDrawThroughTheirOwnHandleAndAreReleased() { + FakeLibhegel fake = new FakeLibhegel(); + fake.integerValue = 4L; + LiveDataSource ds = source(fake); + DataSource clone = ds.cloneForWorker(2); + assertEquals(List.of(FakeLibhegel.TC), fake.clonesMade); + assertEquals(2L, fake.workersSet.get(FakeLibhegel.CLONE_BASE)); + assertEquals(4, clone.generateInteger(0, 10)); + clone.release(); + assertEquals(List.of(FakeLibhegel.CLONE_BASE), fake.freedClones); + // The root handle is untouched by the clone's release. + assertEquals(1, fake.freedTestCases); + } + + @Test + void cloneFailuresPropagateAndLeakNothing() { + FakeLibhegel cloneFails = new FakeLibhegel(); + cloneFails.cloneRc = Abi.E_BACKEND; + assertThrows(HegelException.class, () -> source(cloneFails).cloneForWorker(0)); + assertTrue(cloneFails.freedClones.isEmpty()); + + // A clone past the end of a replayed sequence is an overrun, like any other draw. + FakeLibhegel exhausted = new FakeLibhegel(); + exhausted.cloneRc = Abi.E_STOP_TEST; + LiveDataSource ds0 = source(exhausted); + assertThrows(StopTest.class, () -> ds0.cloneForWorker(0)); + assertTrue(ds0.isAborted()); + + FakeLibhegel workerFails = new FakeLibhegel(); + workerFails.setWorkerFails = true; + assertThrows(HegelException.class, () -> source(workerFails).cloneForWorker(0)); + // The clone was made, so it is released before the failure surfaces. + assertEquals(List.of(FakeLibhegel.CLONE_BASE), workerFails.freedClones); + + // Cloning is a draw: it short-circuits once the case is aborted. + FakeLibhegel aborted = new FakeLibhegel(); + aborted.generateIntegerRc = Abi.E_STOP_TEST; + LiveDataSource ds = source(aborted); + assertThrows(StopTest.class, () -> ds.generateInteger(0, 1)); + assertThrows(StopTest.class, () -> ds.cloneForWorker(0)); + assertTrue(aborted.clonesMade.isEmpty()); + } + + @Test + void concurrentUseOfOneHandleIsExplained() { + FakeLibhegel fake = new FakeLibhegel(); + fake.poolAddRc = Abi.E_CONCURRENT_USE; + HegelException e = assertThrows(HegelException.class, () -> source(fake).poolAdd(3)); + assertTrue(e.getMessage().contains("two threads"), e.getMessage()); + assertTrue(e.getMessage().contains("ConcurrentPool"), e.getMessage()); + } + + @Test + void stringGeneratorConstructionAndOwnership() { + FakeLibhegel fake = new FakeLibhegel(); + LiveDataSource ds = source(fake); + StringGeneratorHandle text = ds.textGenerator(0, 10, null, 0, Abi.NO_MAX_CODEPOINT, null, null, null, null); + StringGeneratorHandle regex = ds.regexGenerator("[a-z]", true, text); + StringGeneratorHandle regexNoAlphabet = ds.regexGenerator("[a-z]", true, null); + StringGeneratorHandle email = ds.emailGenerator(); + StringGeneratorHandle url = ds.urlGenerator(); + StringGeneratorHandle domain = ds.domainGenerator(255); + for (StringGeneratorHandle h : List.of(text, regex, regexNoAlphabet, email, url, domain)) { + assertNotNull(h); + assertTrue(ds.ownsStringGenerator(h)); + } + // A handle built by a different binding is not owned, so caches rebuild it. + assertFalse(source(new FakeLibhegel()).ownsStringGenerator(text)); + } + + @Test + void stopTestUnwindsAndAbortsEverything() { + FakeLibhegel fake = new FakeLibhegel(); + fake.generateIntegerRc = Abi.E_STOP_TEST; + LiveDataSource ds = source(fake); + assertThrows(StopTest.class, () -> ds.generateInteger(0, 1)); + assertTrue(ds.isAborted()); + // Subsequent value-producing primitives short-circuit to StopTest without touching libhegel. + assertThrows(StopTest.class, () -> ds.generateBoolean(0.5)); + assertThrows(StopTest.class, () -> ds.generateInteger(0, 1)); + assertThrows(StopTest.class, () -> ds.generateFloat(64, 0, 1, false, false, false, false, 1e-300)); + assertThrows(StopTest.class, () -> ds.generateBytes(0, 1)); + assertThrows(StopTest.class, () -> ds.generateString(null)); + assertThrows(StopTest.class, () -> ds.generateDate(DATE, DATE)); + assertThrows(StopTest.class, () -> ds.generateTime(TIME, TIME)); + assertThrows(StopTest.class, () -> ds.generateDatetime(DATETIME, DATETIME)); + assertThrows(StopTest.class, () -> ds.generateUuid(4)); + assertThrows(StopTest.class, () -> ds.generateIpv4()); + assertThrows(StopTest.class, () -> ds.generateIpv6()); + assertThrows( + StopTest.class, () -> ds.textGenerator(0, 1, null, 0, Abi.NO_MAX_CODEPOINT, null, null, null, null)); + assertThrows(StopTest.class, () -> ds.regexGenerator("x", true, null)); + assertThrows(StopTest.class, () -> ds.emailGenerator()); + assertThrows(StopTest.class, () -> ds.urlGenerator()); + assertThrows(StopTest.class, () -> ds.domainGenerator(10)); + assertThrows(StopTest.class, () -> ds.startSpan(Label.LIST)); + assertThrows(StopTest.class, () -> ds.newCollection(0, 1)); + assertThrows(StopTest.class, () -> ds.collectionMore(1)); + assertThrows(StopTest.class, () -> ds.collectionReject(1, "x")); + assertThrows(StopTest.class, () -> ds.newPool()); + assertThrows(StopTest.class, () -> ds.poolAdd(1)); + assertThrows(StopTest.class, () -> ds.poolGenerate(1, false)); + assertThrows( + StopTest.class, + () -> ds.newStateMachine(List.of("r"), new long[] {0}, null, List.of(), new boolean[0], 1, 1, 50)); + assertThrows(StopTest.class, () -> ds.stateMachineNextGroup(1)); + assertThrows(StopTest.class, () -> ds.stateMachineNextRule(1, 0)); + assertThrows(StopTest.class, () -> ds.stateMachineRuleRejected(1, 0)); + assertThrows(StopTest.class, () -> ds.stateMachineShouldCheckInvariant(1, 0)); + // Freeing the machine is not a draw: it must still work once the case is aborted. + ds.stateMachineFree(1); + assertEquals(1, fake.freedStateMachines); + assertThrows(StopTest.class, () -> ds.target(1.0, "l")); + // stopSpan is a no-op once aborted (used by span-closing finally blocks). + ds.stopSpan(false); + } + + @Test + void assumeUnwindsAndAborts() { + FakeLibhegel fake = new FakeLibhegel(); + fake.generateStringRc = Abi.E_ASSUME; + LiveDataSource ds = source(fake); + StringGeneratorHandle email = ds.emailGenerator(); + assertThrows(AssumeRejected.class, () -> ds.generateString(email)); + assertTrue(ds.isAborted()); + } + + @Test + void invalidArgBecomesIllegalArgumentException() { + FakeLibhegel fake = new FakeLibhegel(); + fake.stringGeneratorTextRc = Abi.E_INVALID_ARG; + fake.lastError = "empty alphabet"; + LiveDataSource ds = source(fake); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ds.textGenerator(0, 1, null, 0, Abi.NO_MAX_CODEPOINT, List.of(), null, null, null)); + assertTrue(e.getMessage().contains("empty alphabet")); + assertFalse(ds.isAborted()); + } + + @Test + void backendErrorBecomesHegelException() { + FakeLibhegel fake = new FakeLibhegel(); + fake.generateBooleanRc = Abi.E_BACKEND; + fake.lastError = "boom"; + LiveDataSource ds = source(fake); + HegelException e = assertThrows(HegelException.class, () -> ds.generateBoolean(0.5)); + assertTrue(e.getMessage().contains("boom")); + assertFalse(ds.isAborted()); + } + + @Test + void backendErrorWithNullMessage() { + FakeLibhegel fake = new FakeLibhegel(); + fake.generateBooleanRc = Abi.E_BACKEND; + fake.lastError = null; + assertThrows(HegelException.class, () -> source(fake).generateBoolean(0.5)); + } + + @Test + void spanAndCollectionErrorsPropagate() { + FakeLibhegel startSpan = new FakeLibhegel(); + startSpan.startSpanRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(startSpan).startSpan(Label.LIST)); + + FakeLibhegel stopSpan = new FakeLibhegel(); + stopSpan.stopSpanRc = Abi.E_INVALID_HANDLE; + assertThrows(HegelException.class, () -> source(stopSpan).stopSpan(false)); + + FakeLibhegel newCollection = new FakeLibhegel(); + newCollection.newCollectionRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(newCollection).newCollection(0, 5)); + + FakeLibhegel more = new FakeLibhegel(); + more.moreSequence = new boolean[] {true, false}; + LiveDataSource ds = source(more); + assertTrue(ds.collectionMore(1)); + assertFalse(ds.collectionMore(1)); + + FakeLibhegel moreError = new FakeLibhegel(); + moreError.collectionMoreRc = Abi.E_BACKEND; + assertThrows(HegelException.class, () -> source(moreError).collectionMore(1)); + + FakeLibhegel reject = new FakeLibhegel(); + reject.collectionRejectRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(reject).collectionReject(1, "dup")); + + FakeLibhegel target = new FakeLibhegel(); + target.targetRc = Abi.E_ASSUME; + assertThrows(AssumeRejected.class, () -> source(target).target(1.0, "l")); + } + + @Test + void poolAndStateMachineErrorsPropagate() { + FakeLibhegel pool = new FakeLibhegel(); + pool.newPoolRc = Abi.E_BACKEND; + assertThrows(HegelException.class, () -> source(pool).newPool()); + + FakeLibhegel add = new FakeLibhegel(); + add.poolAddRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(add).poolAdd(1)); + + FakeLibhegel gen = new FakeLibhegel(); + gen.poolGenerateRc = Abi.E_ASSUME; + assertThrows(AssumeRejected.class, () -> source(gen).poolGenerate(1, true)); + + FakeLibhegel sm = new FakeLibhegel(); + sm.newStateMachineRc = Abi.E_INVALID_ARG; + assertThrows( + IllegalArgumentException.class, + () -> source(sm) + .newStateMachine(List.of("r"), new long[] {0}, null, List.of(), new boolean[0], 1, 1, 50)); + + FakeLibhegel group = new FakeLibhegel(); + group.stateMachineNextGroupRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(group).stateMachineNextGroup(1)); + + FakeLibhegel next = new FakeLibhegel(); + next.stateMachineNextRuleRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(next).stateMachineNextRule(1, 0)); + + FakeLibhegel rejected = new FakeLibhegel(); + rejected.stateMachineRuleRejectedRc = Abi.E_INVALID_ARG; + assertThrows(IllegalArgumentException.class, () -> source(rejected).stateMachineRuleRejected(1, 0)); + + FakeLibhegel check = new FakeLibhegel(); + check.stateMachineShouldCheckInvariantRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> source(check).stateMachineShouldCheckInvariant(1, 0)); + } +} diff --git a/shared/src/test/java/dev/hegel/ConcurrentCounterExample.java b/shared/src/test/java/dev/hegel/ConcurrentCounterExample.java new file mode 100644 index 0000000..bc39daa --- /dev/null +++ b/shared/src/test/java/dev/hegel/ConcurrentCounterExample.java @@ -0,0 +1,66 @@ +package dev.hegel; + +import static dev.hegel.Generators.text; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * The concurrent stateful example from the release notes and the {@link Stateful} Javadoc, as a + * runnable demonstration. It is meant to fail — the counter loses updates under contention — so it + * is skipped unless asked for: + * + *
    + * mvn test -pl hegel -am -Dtest=ConcurrentCounterExample -Dhegel.examples=true \
    + *     -Dsurefire.failIfNoSpecifiedTests=false
    + * 
    + * + * (or {@code -pl hegel-jna -am} for the JNA frontend). The report shows the round headers and the + * worker-stamped rule and draw lines that led to the lost update. + */ +@EnabledIfSystemProperty(named = "hegel.examples", matches = "true") +class ConcurrentCounterExample { + static final class Counter { + private final Map store = new ConcurrentHashMap<>(); + private final AtomicInteger increments = new AtomicInteger(); + private final ConcurrentPool keys; + + Counter(TestCase tc) { + keys = new ConcurrentPool<>(tc); + } + + @Rule(group = "ops") + void register(TestCase tc) { + String key = tc.draw(text().minSize(1).maxSize(3)); + store.putIfAbsent(key, 0); + keys.add(tc, key); + } + + @Rule(group = "ops", weight = 3) + void increment(TestCase tc) { + String key = tc.draw(keys.reusable()); + store.put(key, store.get(key) + 1); // racy: a lost update + increments.incrementAndGet(); + } + + @Rule(group = "audit") + void audit(TestCase tc) { + tc.note("store holds " + store.size() + " keys"); + } + + @Invariant + void noLostUpdates(TestCase tc) { + assertEquals( + increments.get(), + store.values().stream().mapToInt(Integer::intValue).sum()); + } + } + + @HegelTest(database = Database.DISABLED) + void counterUnderContention(TestCase tc) { + Stateful.run(new Counter(tc), tc, Stateful.options().maxConcurrency(4)); + } +} diff --git a/shared/src/test/java/dev/hegel/ConcurrentStatefulDriverTest.java b/shared/src/test/java/dev/hegel/ConcurrentStatefulDriverTest.java new file mode 100644 index 0000000..5c9f801 --- /dev/null +++ b/shared/src/test/java/dev/hegel/ConcurrentStatefulDriverTest.java @@ -0,0 +1,428 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * The concurrent stateful driver over the fake engine: per-worker rule queues make the round + * protocol, the join-point output layout and every outcome-resolution path deterministic. + */ +class ConcurrentStatefulDriverTest { + private static final Pattern STAMP = Pattern.compile("^\\[worker (\\d+) \\+\\d+\\.\\d{3}ms\\] "); + + /** A fake reporting {@code concurrency} workers and playing the given rounds. */ + private static FakeLibhegel concurrentFake(int concurrency, long[][]... rounds) { + FakeLibhegel fake = new FakeLibhegel(); + fake.stateMachineConcurrency = concurrency; + fake.concurrentRounds = rounds; + return fake; + } + + private static TestCase exploringCase(FakeLibhegel fake) { + return new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), false, Reporter.silent()); + } + + private static TestCase capturedCase(FakeLibhegel fake) { + return new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), true, Reporter.silent()); + } + + private static Stateful.Options twoWorkers() { + return Stateful.options().minConcurrency(2).maxConcurrency(2); + } + + /** Notes with the timing dropped from their worker stamps: {@code [worker N] ...}. */ + private static List unstamped(List lines) { + return lines.stream() + .map(line -> STAMP.matcher(line).replaceFirst("[worker $1] ")) + .toList(); + } + + private static List sorted(List values) { + List copy = new ArrayList<>(values); + Collections.sort(copy); + return copy; + } + + /** Rules alpha (anonymous), beta and gamma (group "ops"); beta always rejects, gamma draws. */ + static final class Recording { + final List applied = Collections.synchronizedList(new ArrayList<>()); + final Set threads = ConcurrentHashMap.newKeySet(); + final Set workers = ConcurrentHashMap.newKeySet(); + int sampledChecks; + int alwaysChecks; + + @Rule + void alpha(TestCase t) { + applied.add("alpha"); + threads.add(Thread.currentThread().getName()); + workers.add(t.worker()); + } + + @Rule(group = "ops") + void beta(TestCase t) { + applied.add("beta"); + t.assume(false); + } + + @Rule(group = "ops") + void gamma(TestCase t) { + applied.add("gamma"); + t.draw(integers().min(0).max(9), "x"); + } + + @Invariant + void sampled(TestCase t) { + sampledChecks++; + } + + @Invariant(alwaysRun = true) + void unsampled(TestCase t) { + alwaysChecks++; + } + } + + @Test + void driverFollowsTheRoundProtocolOnWorkerThreads() { + // Round 1: worker 0 runs alpha then gamma, worker 1 runs gamma. Round 2: only worker 1 runs alpha. + FakeLibhegel fake = concurrentFake(2, new long[][] {{0, 2}, {2}}, new long[][] {{}, {0}}); + Recording machine = new Recording(); + TestCase tc = capturedCase(fake); + Stateful.run(machine, tc, twoWorkers()); + + // Registration: groups numbered in first-appearance order over the name-sorted rules. + assertEquals(List.of("alpha", "beta", "gamma"), fake.stateMachineRules); + assertArrayEquals(new long[] {0, 1, 1}, fake.stateMachineRuleGroups); + assertEquals(2, fake.stateMachineMinConcurrency); + assertEquals(2, fake.stateMachineMaxConcurrency); + // Rules ran on worker threads, each worker pulling with its own index until its join point. + assertEquals(List.of("alpha", "alpha", "gamma", "gamma"), sorted(machine.applied)); + assertTrue(machine.threads.stream().allMatch(n -> n.startsWith("hegel-worker-")), machine.threads.toString()); + assertEquals(Set.of(0, 1), machine.workers); + assertEquals(-1, tc.worker()); + assertEquals(4, fake.nextRuleWorkers.stream().filter(w -> w == 0).count()); + assertEquals(4, fake.nextRuleWorkers.stream().filter(w -> w == 1).count()); + // One clone per worker per round, attributed to its worker, every one released; the machine too. + assertEquals(4, fake.clonesMade.size()); + for (int i = 0; i < 4; i++) { + assertEquals(Long.valueOf(i % 2), fake.workersSet.get(FakeLibhegel.CLONE_BASE + i)); + } + assertEquals( + List.of( + FakeLibhegel.CLONE_BASE, + FakeLibhegel.CLONE_BASE + 1, + FakeLibhegel.CLONE_BASE + 2, + FakeLibhegel.CLONE_BASE + 3), + fake.freedClones); + assertEquals(1, fake.freedStateMachines); + // Invariants: initial, one sampled join point per round (the fake samples everything in), final. + assertEquals(4, machine.sampledChecks); + assertEquals(4, machine.alwaysChecks); + assertEquals(List.of(0L, 1L, 0L, 1L), fake.invariantChecksAsked); + // Output: each round's worker lines follow its header, grouped by worker index. + assertEquals( + List.of( + "Concurrency level: 2", + "Checking invariants on the initial state.", + "---------------- Round 1: group \"\" ----------------", + "[worker 0] Rule: alpha", + "[worker 0] Rule: gamma", + "[worker 1] Rule: gamma", + "---------------- Round 2: group \"\" ----------------", + "[worker 1] Rule: alpha", + "Checking invariants on the final state."), + unstamped(tc.notes())); + // Draw names are unique across workers and recorded plain; the replayed line carries the stamp. + assertEquals(Set.of("x", "x_2"), tc.draws().keySet()); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + tc.replayTo(Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8))); + List printed = + unstamped(List.of(buf.toString(StandardCharsets.UTF_8).split("\\R"))); + assertTrue( + printed.contains("[worker 0] x = 0;") || printed.contains("[worker 0] x_2 = 0;"), printed.toString()); + assertTrue( + printed.indexOf("[worker 0] Rule: gamma") < printed.indexOf("[worker 1] Rule: gamma"), + printed.toString()); + } + + @Test + void verboseRunsHearWorkerLinesAtTheJoinPoint() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{0}, {2}}); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = new TestCase( + new LiveDataSource(fake, FakeLibhegel.TC), + false, + true, + Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8))); + Stateful.run(new Recording(), tc, twoWorkers()); + List printed = + unstamped(List.of(buf.toString(StandardCharsets.UTF_8).split("\\R"))); + assertTrue(printed.contains("[worker 0] Rule: alpha"), printed.toString()); + assertTrue(printed.contains("[worker 1] Rule: gamma"), printed.toString()); + assertTrue(printed.contains("[worker 1] x = 0;"), printed.toString()); + // Not captured: nothing is kept for a report. + assertTrue(tc.notes().isEmpty()); + } + + @Test + void roundHeadersNameTheGroupAndRejectionsAreReportedPerWorker() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{1}, {}}); + fake.stateMachineGroupId = 1; // the "ops" group + TestCase tc = capturedCase(fake); + Stateful.run(new Recording(), tc, twoWorkers()); + List notes = unstamped(tc.notes()); + assertTrue(notes.contains("---------------- Round 1: group \"ops\" ----------------"), notes.toString()); + int rule = notes.indexOf("[worker 0] Rule: beta"); + assertTrue(rule >= 0, notes.toString()); + assertEquals("[worker 0] Rule stopped early due to violated assumption.", notes.get(rule + 1)); + assertEquals(List.of(0L), fake.rejectedWorkers); + } + + @Test + void anEngineLevelRejectionInsideARuleInvalidatesTheCase() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{2}, {}}); + fake.generateIntegerRc = Abi.E_ASSUME; + TestCase tc = exploringCase(fake); + assertThrows(AssumeRejected.class, () -> Stateful.run(new Recording(), tc, twoWorkers())); + assertEquals(0, fake.rejectedRules); + assertEquals(2, fake.freedClones.size()); + assertEquals(1, fake.freedStateMachines); + } + + /** Rules that end a round each in their own way: bad (usage error), crashes, draws, fails. */ + static final class Outcomes { + @Rule + void bad(TestCase t) { + throw new IllegalArgumentException("usage"); + } + + @Rule + void crashes(TestCase t) { + throw new IllegalStateException("crashed"); + } + + @Rule + void draws(TestCase t) { + t.draw(integers()); + } + + @Rule + void fails(TestCase t) { + throw new AssertionError("boom"); + } + } + + @Test + void anOverrunOutranksAFailureFoundAlongside() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{2}, {3}}); + fake.generateIntegerRc = Abi.E_STOP_TEST; + TestCase tc = capturedCase(fake); + assertThrows(StopTest.class, () -> Stateful.run(new Outcomes(), tc, twoWorkers())); + assertTrue( + tc.notes().contains("Dropped concurrent failure from worker 1: java.lang.AssertionError: boom"), + tc.notes().toString()); + } + + @Test + void anInvalidConclusionOutranksAFailureFoundAlongside() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{3}, {2}}); + fake.generateIntegerRc = Abi.E_ASSUME; + TestCase tc = capturedCase(fake); + assertThrows(AssumeRejected.class, () -> Stateful.run(new Outcomes(), tc, twoWorkers())); + assertTrue( + tc.notes().contains("Dropped concurrent failure from worker 0: java.lang.AssertionError: boom"), + tc.notes().toString()); + } + + @Test + void controlErrorsOutrankFailures() { + // A usage error in worker 1 beats a failure in worker 0. + FakeLibhegel usage = concurrentFake(2, new long[][] {{3}, {0}}); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> Stateful.run(new Outcomes(), exploringCase(usage), twoWorkers())); + assertEquals("usage", e.getMessage()); + + // A binding error inside a worker (here from rule_rejected) surfaces verbatim. + FakeLibhegel binding = concurrentFake(2, new long[][] {{1}, {}}); + binding.stateMachineRuleRejectedRc = Abi.E_BACKEND; + assertThrows(HegelException.class, () -> Stateful.run(new Recording(), exploringCase(binding), twoWorkers())); + assertEquals(2, binding.freedClones.size()); + } + + @Test + void theLowestWorkersFailureWinsAndTheRestAreNoted() { + FakeLibhegel errorFirst = concurrentFake(2, new long[][] {{3}, {1}}); + TestCase tc = capturedCase(errorFirst); + AssertionError error = assertThrows(AssertionError.class, () -> Stateful.run(new Outcomes(), tc, twoWorkers())); + assertEquals("boom", error.getMessage()); + assertTrue( + tc.notes() + .contains("Dropped concurrent failure from worker 1: java.lang.IllegalStateException: crashed"), + tc.notes().toString()); + + FakeLibhegel exceptionFirst = concurrentFake(2, new long[][] {{1}, {3}}); + TestCase tc2 = capturedCase(exceptionFirst); + IllegalStateException crashed = + assertThrows(IllegalStateException.class, () -> Stateful.run(new Outcomes(), tc2, twoWorkers())); + assertEquals("crashed", crashed.getMessage()); + assertTrue( + tc2.notes().contains("Dropped concurrent failure from worker 1: java.lang.AssertionError: boom"), + tc2.notes().toString()); + } + + @Test + void anOutOfRangeRuleIndexIsAnInternalError() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{9}, {}}); + HegelException e = assertThrows( + HegelException.class, () -> Stateful.run(new Recording(), exploringCase(fake), twoWorkers())); + assertTrue(e.getMessage().contains("out-of-range"), e.getMessage()); + } + + @Test + void cloneFailuresAbortTheCaseAndReleaseEverything() { + FakeLibhegel cloneFails = concurrentFake(2, new long[][] {{0}, {}}); + cloneFails.cloneRc = Abi.E_BACKEND; + assertThrows( + HegelException.class, () -> Stateful.run(new Recording(), exploringCase(cloneFails), twoWorkers())); + assertEquals(1, cloneFails.freedStateMachines); + + // On a replay of a shorter sequence the clone itself overruns; the case is an overrun. + FakeLibhegel exhausted = concurrentFake(2, new long[][] {{0}, {}}); + exhausted.cloneRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> Stateful.run(new Recording(), exploringCase(exhausted), twoWorkers())); + assertEquals(1, exhausted.freedStateMachines); + + FakeLibhegel workerFails = concurrentFake(2, new long[][] {{0}, {}}); + workerFails.setWorkerFails = true; + assertThrows( + HegelException.class, () -> Stateful.run(new Recording(), exploringCase(workerFails), twoWorkers())); + assertEquals(List.of(FakeLibhegel.CLONE_BASE), workerFails.freedClones); + assertEquals(1, workerFails.freedStateMachines); + } + + /** A rule that sets its own thread's interrupt flag, so the worker exits between rounds. */ + static final class SelfInterrupting { + @Rule + void interrupt(TestCase t) { + Thread.currentThread().interrupt(); + } + } + + @Test + void aWorkerThatExitsWithoutReportingIsAnInternalError() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{0}, {}}, new long[][] {{0}, {}}); + HegelException e = assertThrows( + HegelException.class, () -> Stateful.run(new SelfInterrupting(), exploringCase(fake), twoWorkers())); + assertTrue(e.getMessage().contains("worker 0 exited without reporting"), e.getMessage()); + // Both rounds' clones were released before the round was judged. + assertEquals(4, fake.freedClones.size()); + assertEquals(1, fake.freedStateMachines); + } + + /** A rule that interrupts the driving thread while it waits at the join point. */ + static final class DriverInterrupting { + final Thread driver = Thread.currentThread(); + + @Rule + void interrupt(TestCase t) { + driver.interrupt(); + } + } + + @Test + void anInterruptedDriverStopsItsWorkersAndKeepsTheFlag() { + FakeLibhegel fake = concurrentFake(2, new long[][] {{0}, {}}); + HegelException e = assertThrows( + HegelException.class, () -> Stateful.run(new DriverInterrupting(), exploringCase(fake), twoWorkers())); + // The flag is restored for the caller; clear it so it does not leak into later tests. + assertTrue(Thread.interrupted(), "the interrupt flag was not restored"); + assertTrue(e.getMessage().contains("interrupted while waiting"), e.getMessage()); + // The round's handles were released once the workers had stopped. + assertEquals(2, fake.freedClones.size()); + assertEquals(1, fake.freedStateMachines); + } + + @Test + void optionsValidateAndExposeTheirValues() { + Stateful.Options options = + Stateful.options().stepCount(7).minConcurrency(2).maxConcurrency(5); + assertEquals(7, options.stepCount()); + assertEquals(2, options.minConcurrency()); + assertEquals(5, options.maxConcurrency()); + assertEquals(Stateful.DEFAULT_STEP_COUNT, Stateful.options().stepCount()); + assertEquals(1, Stateful.options().minConcurrency()); + assertEquals(1, Stateful.options().maxConcurrency()); + assertTrue(assertThrows( + IllegalArgumentException.class, () -> Stateful.options().stepCount(0)) + .getMessage() + .contains("stepCount")); + assertTrue(assertThrows( + IllegalArgumentException.class, () -> Stateful.options().minConcurrency(0)) + .getMessage() + .contains("minConcurrency")); + assertTrue(assertThrows( + IllegalArgumentException.class, () -> Stateful.options().maxConcurrency(0)) + .getMessage() + .contains("maxConcurrency")); + + FakeLibhegel fake = new FakeLibhegel(); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> Stateful.run( + new Recording(), + exploringCase(fake), + Stateful.options().minConcurrency(3).maxConcurrency(2))); + assertTrue(e.getMessage().contains("must not exceed"), e.getMessage()); + // Rejected before anything reached the engine. + assertEquals(-1, fake.stateMachineStepCount); + } + + @Test + void maxConcurrencyOneRunsSequentiallyOnTheCallingThread() { + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0}; + Recording machine = new Recording(); + TestCase tc = capturedCase(fake); + Stateful.run(machine, tc, Stateful.options().maxConcurrency(1).stepCount(9)); + assertEquals(Set.of(Thread.currentThread().getName()), machine.threads); + assertEquals(Set.of(-1), machine.workers); + assertTrue(fake.clonesMade.isEmpty()); + assertTrue(tc.notes().contains("Step 1: alpha"), tc.notes().toString()); + assertFalse(tc.notes().contains("Concurrency level: 1"), tc.notes().toString()); + assertEquals(9, fake.stateMachineStepCount); + // Groups are registered for sequential machines too; they have no effect at concurrency 1. + assertArrayEquals(new long[] {0, 1, 1}, fake.stateMachineRuleGroups); + } + + @Test + void concurrentPoolTracksValuesUnderItsLock() { + FakeLibhegel fake = new FakeLibhegel(); + TestCase tc = exploringCase(fake); + ConcurrentPool pool = new ConcurrentPool<>(tc); + assertTrue(pool.isEmpty()); + assertThrows(AssumeRejected.class, () -> tc.draw(pool.reusable())); + pool.add(tc, "a"); + assertFalse(pool.isEmpty()); + assertEquals(1, pool.size()); + assertEquals("a", tc.draw(pool.reusable())); + assertEquals(1, pool.size()); + assertEquals("a", tc.draw(pool.consuming())); + assertEquals(0, pool.size()); + assertThrows(AssumeRejected.class, () -> tc.draw(pool.consuming())); + } +} diff --git a/shared/src/test/java/dev/hegel/ConcurrentStatefulTest.java b/shared/src/test/java/dev/hegel/ConcurrentStatefulTest.java new file mode 100644 index 0000000..d285a1e --- /dev/null +++ b/shared/src/test/java/dev/hegel/ConcurrentStatefulTest.java @@ -0,0 +1,458 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** Concurrent stateful testing against the real engine. */ +class ConcurrentStatefulTest { + private static final Pattern WORKER_RULE_LINE = + Pattern.compile("\\[worker [0-9]+ \\+[0-9]+\\.[0-9]{3}ms\\] Rule: boom"); + private static final Pattern WORKER_DRAW_LINE = + Pattern.compile("\\[worker [0-9]+ \\+[0-9]+\\.[0-9]{3}ms\\] x(_[0-9]+)? = -?[0-9]+;"); + + private static Settings settings() { + return new Settings().database(Database.disabled()); + } + + private static Stateful.Options exactly(int workers) { + return Stateful.options().minConcurrency(workers).maxConcurrency(workers); + } + + private static boolean anyMatches(List lines, Pattern pattern) { + return lines.stream().anyMatch(line -> pattern.matcher(line).find()); + } + + /** A thread-safe counter whose decrement has a precondition, so rules are rejected at every level. */ + static final class ConcurrentCounter { + private final AtomicInteger n = new AtomicInteger(); + + @Rule + void increment(TestCase tc) { + n.incrementAndGet(); + } + + @Rule + void decrement(TestCase tc) { + tc.assume(n.get() > 0); + n.updateAndGet(v -> Math.max(0, v - 1)); + } + + @Invariant + void nonNegative(TestCase tc) { + assertTrue(n.get() >= 0); + } + } + + @HegelTest(database = Database.DISABLED) + void counterHoldsAtEveryConcurrencyLevel(TestCase tc) { + Stateful.run(new ConcurrentCounter(), tc, Stateful.options().maxConcurrency(3)); + } + + /** Named groups alongside the anonymous one, with a rule that rejects. */ + static final class Grouped { + private final AtomicInteger letters = new AtomicInteger(); + private final AtomicInteger numbers = new AtomicInteger(); + + @Rule(group = "letters") + void a(TestCase tc) { + letters.incrementAndGet(); + } + + @Rule(group = "letters", weight = 2) + void b(TestCase tc) { + letters.incrementAndGet(); + } + + @Rule(group = "numbers") + void one(TestCase tc) { + tc.assume(numbers.get() != 3); + numbers.incrementAndGet(); + } + + @Rule + void reset(TestCase tc) { + numbers.set(0); + } + + @Invariant + void counts(TestCase tc) { + assertTrue(letters.get() >= 0 && numbers.get() >= 0); + } + } + + @HegelTest(database = Database.DISABLED) + void groupedMachinePasses(TestCase tc) { + Stateful.run(new Grouped(), tc, Stateful.options().maxConcurrency(3)); + } + + /** Records which threads run its rules and whether two rules ever overlapped in time. */ + static final class Probe { + final Set threads = ConcurrentHashMap.newKeySet(); + private final AtomicInteger active = new AtomicInteger(); + volatile boolean overlapped; + volatile boolean invariantSawActiveRule; + + @Rule + void touch(TestCase tc) { + threads.add(Thread.currentThread().getId()); + if (active.incrementAndGet() > 1) { + overlapped = true; + } + LockSupport.parkNanos(20_000); + active.decrementAndGet(); + } + + @Invariant(alwaysRun = true) + void quiet(TestCase tc) { + if (active.get() != 0) { + invariantSawActiveRule = true; + } + } + } + + @Test + void severalWorkersRunRulesInParallelAndInvariantsRunBetweenRounds() { + long driver = Thread.currentThread().getId(); + Set workerCounts = ConcurrentHashMap.newKeySet(); + boolean[] overlapped = {false}; + boolean[] invariantOverlap = {false}; + Hegel.run( + tc -> { + Probe probe = new Probe(); + Stateful.run(probe, tc, exactly(3).stepCount(5)); + workerCounts.add(probe.threads.size()); + assertFalse(probe.threads.contains(driver), "a rule ran on the driving thread"); + overlapped[0] |= probe.overlapped; + invariantOverlap[0] |= probe.invariantSawActiveRule; + }, + settings().testCases(50), + Reporter.silent()) + .throwIfFailed(); + assertTrue(workerCounts.contains(3), "expected some case to use all three workers: " + workerCounts); + assertTrue(overlapped[0], "expected two rules to run at the same time in some case"); + assertFalse(invariantOverlap[0], "an invariant observed a rule in flight"); + } + + @Test + void defaultOptionsRunOnTheCallingThread() { + long driver = Thread.currentThread().getId(); + Hegel.run( + tc -> { + Probe probe = new Probe(); + Stateful.run(probe, tc); + assertEquals(Set.of(driver), probe.threads); + }, + settings().testCases(5), + Reporter.silent()) + .throwIfFailed(); + } + + /** Rules in three groups that fail if a rule of another group is in flight at the same time. */ + static final class Exclusive { + private final AtomicInteger[] active = {new AtomicInteger(), new AtomicInteger(), new AtomicInteger()}; + final Set groupsSeen = ConcurrentHashMap.newKeySet(); + volatile boolean overlapAcrossGroups; + + private void run(int group) { + groupsSeen.add(group); + active[group].incrementAndGet(); + for (int other = 0; other < active.length; other++) { + if (other != group && active[other].get() > 0) { + overlapAcrossGroups = true; + } + } + LockSupport.parkNanos(20_000); + active[group].decrementAndGet(); + } + + @Rule(group = "a") + void a1(TestCase tc) { + run(0); + } + + @Rule(group = "a") + void a2(TestCase tc) { + run(0); + } + + @Rule(group = "b") + void b(TestCase tc) { + run(1); + } + + @Rule + void anonymous(TestCase tc) { + run(2); + } + } + + @Test + void rulesOfDifferentGroupsNeverOverlap() { + Set groupsSeen = ConcurrentHashMap.newKeySet(); + boolean[] overlap = {false}; + Hegel.run( + tc -> { + Exclusive machine = new Exclusive(); + Stateful.run(machine, tc, exactly(4).stepCount(10)); + groupsSeen.addAll(machine.groupsSeen); + overlap[0] |= machine.overlapAcrossGroups; + }, + settings().testCases(50), + Reporter.silent()) + .throwIfFailed(); + assertFalse(overlap[0], "rules from two groups ran at the same time"); + assertEquals(Set.of(0, 1, 2), groupsSeen); + } + + /** A rule that always fails, after a labelled draw so the report has a worker-stamped draw line. */ + static final class Boom { + private final boolean armed; + + Boom(boolean armed) { + this.armed = armed; + } + + @Rule + void boom(TestCase tc) { + int x = tc.draw(integers().min(0).max(1000), "x"); + if (armed) { + throw new IllegalStateException("concurrent boom " + x); + } + } + } + + @Test + void aWorkerFailureIsReportedWithItsRoundAndWorkerLines() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + RunReport report = Hegel.run( + tc -> Stateful.run(new Boom(true), tc, exactly(2)), + settings().seed(3), + Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8))); + assertEquals(RunStatus.FAILED, report.status()); + Failure failure = report.failures().get(0); + IllegalStateException e = assertInstanceOf(IllegalStateException.class, failure.exception()); + assertTrue(e.getMessage().startsWith("concurrent boom"), e.getMessage()); + List notes = failure.notes(); + assertTrue(notes.contains("Concurrency level: 2"), notes.toString()); + assertTrue( + notes.contains("---------------- Round 1: group \"" + Stateful.ANONYMOUS_GROUP + "\" ----------------"), + notes.toString()); + assertTrue(anyMatches(notes, WORKER_RULE_LINE), notes.toString()); + // Draws are recorded under their plain names; the stamp is only on the printed line. + for (String name : failure.draws().keySet()) { + assertTrue(name.matches("x(_[0-9]+)?"), name); + } + String printed = buf.toString(StandardCharsets.UTF_8); + assertTrue(anyMatches(List.of(printed.split("\\R")), WORKER_DRAW_LINE), printed); + // The failure is the body's own exception, rethrown as-is by test(). + assertThrows(IllegalStateException.class, report::throwIfFailed); + } + + @Test + void aConcurrentFailureBlobReplaysAndGoesStaleOnceFixed() { + RunReport report = Hegel.run( + tc -> Stateful.run(new Boom(true), tc, exactly(2)), settings().seed(3), Reporter.silent()); + assertEquals(RunStatus.FAILED, report.status()); + String blob = report.failures().get(0).reproduceBlob().orElseThrow(); + + RunReport replayed = Hegel.run( + tc -> Stateful.run(new Boom(true), tc, exactly(2)), + settings().reproduceFailure(blob), + Reporter.silent()); + assertEquals(RunStatus.FAILED, replayed.status()); + assertInstanceOf(IllegalStateException.class, replayed.failures().get(0).exception()); + + HegelException stale = assertThrows( + HegelException.class, + () -> Hegel.run( + tc -> Stateful.run(new Boom(false), tc, exactly(2)), + settings().reproduceFailure(blob), + Reporter.silent())); + assertEquals(Runner.STALE_BLOB, stale.getMessage()); + } + + /** A key-value store whose increment reads, yields, then writes: a lost-update race. */ + static final class RacyStore { + private final Map store = new ConcurrentHashMap<>(); + private final AtomicInteger increments = new AtomicInteger(); + private final ConcurrentPool keys; + + RacyStore(TestCase tc) { + keys = new ConcurrentPool<>(tc); + } + + @Rule(group = "ops") + void register(TestCase tc) { + int key = tc.draw(integers().min(0).max(3)); + if (store.putIfAbsent(key, 0) == null) { + keys.add(tc, key); + } + } + + @Rule(group = "ops", weight = 3) + void increment(TestCase tc) { + int key = tc.draw(keys.reusable()); + int value = store.get(key); + LockSupport.parkNanos(10_000); + store.put(key, value + 1); + increments.incrementAndGet(); + } + + @Rule(group = "audit") + void audit(TestCase tc) { + tc.note("store holds " + store.size() + " keys"); + } + + @Invariant(alwaysRun = true) + void noLostUpdates(TestCase tc) { + int total = store.values().stream().mapToInt(Integer::intValue).sum(); + assertEquals(increments.get(), total, "lost update"); + } + } + + @Test + void aLostUpdateRaceIsFound() { + RunReport report = Hegel.run( + tc -> Stateful.run( + new RacyStore(tc), + tc, + Stateful.options().maxConcurrency(4).stepCount(10)), + settings().testCases(100), + Reporter.silent()); + assertEquals(RunStatus.FAILED, report.status(), String.valueOf(report.error())); + Failure failure = report.failures().get(0); + AssertionError e = assertInstanceOf(AssertionError.class, failure.exception()); + assertTrue(e.getMessage().contains("lost update"), e.getMessage()); + } + + /** Rules that add and consume values through a pool shared by every worker. */ + static final class PoolMachine { + private final Set live = ConcurrentHashMap.newKeySet(); + private final AtomicInteger next = new AtomicInteger(); + private final ConcurrentPool pool; + + PoolMachine(TestCase tc) { + pool = new ConcurrentPool<>(tc); + } + + @Rule + void create(TestCase tc) { + int v = next.getAndIncrement(); + live.add(v); + pool.add(tc, v); + } + + @Rule + void reuse(TestCase tc) { + int v = tc.draw(pool.reusable()); + // Another worker may consume v between the draw and this check, so only ask whether + // it was ever created. + assertTrue(v >= 0 && v < next.get(), "reused a value never created: " + v); + } + + @Rule + void consume(TestCase tc) { + int v = tc.draw(pool.consuming()); + assertTrue(live.remove(v), "consumed a value twice or never created: " + v); + } + + @Invariant(alwaysRun = true) + void nothingLost(TestCase tc) { + assertEquals(live.size(), pool.size()); + } + } + + @HegelTest(database = Database.DISABLED) + void concurrentPoolsHandOutEachValueToOneConsumer(TestCase tc) { + Stateful.run(new PoolMachine(tc), tc, Stateful.options().maxConcurrency(4)); + } + + /** A rule that exhausts the engine's choice budget once, in the run's first case. */ + static final class Greedy { + private final AtomicBoolean drained; + + Greedy(AtomicBoolean drained) { + this.drained = drained; + } + + @Rule + void drain(TestCase tc) { + if (drained.getAndSet(true)) { + return; + } + for (int i = 0; i < 2_000_000; i++) { + tc.draw(integers()); + } + } + } + + @Test + void anOverrunningWorkerConcludesTheCaseAsAnOverrun() { + // Draining the budget costs about a million engine calls, so only the first case does it; + // the engine then counts that case as an overrun and moves on to a valid one. + AtomicBoolean drained = new AtomicBoolean(); + RunReport report = Hegel.run( + tc -> Stateful.run(new Greedy(drained), tc, exactly(2)), + settings().testCases(1), + Reporter.silent()); + assertEquals(RunStatus.PASSED, report.status(), String.valueOf(report.error())); + assertEquals(1, report.statistics().overrun()); + assertEquals(1, report.statistics().valid()); + } + + /** An invariant whose assumption fails at a join point invalidates the case. */ + static final class Picky { + @Rule + void step(TestCase tc) {} + + @Invariant(alwaysRun = true) + void never(TestCase tc) { + tc.assume(false); + } + } + + @Test + void anInvariantAssumptionFailureInvalidatesTheCase() { + RunReport report = Hegel.run( + tc -> Stateful.run(new Picky(), tc, exactly(2)), settings().testCases(5), Reporter.silent()); + assertEquals(RunStatus.ERROR, report.status()); + assertTrue(report.healthCheckFailed(), String.valueOf(report.error())); + } + + static final class NoRules { + @Invariant + void lonely(TestCase tc) {} + } + + @HegelTest(database = Database.DISABLED, testCases = 1) + void invalidOptionsAndMachinesAreRejectedBeforeReachingTheEngine(TestCase tc) { + IllegalArgumentException bounds = assertThrows( + IllegalArgumentException.class, + () -> Stateful.run( + new ConcurrentCounter(), + tc, + Stateful.options().minConcurrency(2).maxConcurrency(1))); + assertTrue(bounds.getMessage().contains("minConcurrency"), bounds.getMessage()); + assertThrows(IllegalArgumentException.class, () -> Stateful.options().minConcurrency(0)); + assertThrows(IllegalArgumentException.class, () -> Stateful.options().maxConcurrency(0)); + assertThrows(IllegalArgumentException.class, () -> Stateful.options().stepCount(0)); + assertThrows(IllegalArgumentException.class, () -> Stateful.run(new NoRules(), tc, exactly(2))); + } +} diff --git a/src/test/java/dev/hegel/ConformanceTest.java b/shared/src/test/java/dev/hegel/ConformanceTest.java similarity index 84% rename from src/test/java/dev/hegel/ConformanceTest.java rename to shared/src/test/java/dev/hegel/ConformanceTest.java index 58638d7..2ed23f4 100644 --- a/src/test/java/dev/hegel/ConformanceTest.java +++ b/shared/src/test/java/dev/hegel/ConformanceTest.java @@ -42,6 +42,8 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; /** Behaviour/conformance suite exercising every generator against the real engine. */ @@ -83,9 +85,13 @@ void floatsRespectBoundsAndSpecials() { @Test void textRespectsLengthAndCharacters() { assertAllExamples(text().minSize(1).maxSize(3), s -> cp(s) >= 1 && cp(s) <= 3); + // The engine's Unicode tables may be newer than this JVM's (e.g. JDK 17 knows Unicode 13): + // a codepoint the JVM has no data for reads as UNASSIGNED and cannot be classified, so only + // codepoints the JVM knows are held to the category. assertAllExamples( text().categories("Nd").minSize(1).maxSize(2), - s -> s.codePoints().allMatch(Character::isDigit)); + s -> s.codePoints() + .allMatch(c -> Character.isDigit(c) || Character.getType(c) == Character.UNASSIGNED)); assertAllExamples( text().codepoints('a', 'z').minSize(1).maxSize(2), s -> s.codePoints().allMatch(c -> c >= 'a' && c <= 'z')); @@ -192,8 +198,19 @@ void formatGenerators() { ipAddresses().v4(), s -> s.chars().filter(c -> c == '.').count() == 3); assertAllExamples(ipAddresses().v6(), s -> s.contains(":")); assertAllExamples(ipAddresses(), s -> s.contains(".") || s.contains(":")); - assertAllExamples(uuids(), s -> s.contains("-")); - assertAllExamples(fromRegex("[0-9]{3}"), s -> s.matches(".*[0-9]{3}.*")); + assertAllExamples(uuids(), Objects::nonNull); + } + + @Test + void regexDefaultsToFullmatch() { + // By default the entire string matches the pattern (String.matches is a full match). + assertAllExamples(fromRegex("[0-9]{3}"), s -> s.matches("[0-9]{3}")); + // fullmatch(false) relaxes to contains-a-match: every example contains a match... + assertAllExamples( + fromRegex("[0-9]{3}").fullmatch(false), + s -> Pattern.compile("[0-9]{3}").matcher(s).find()); + // ...and surrounding characters genuinely occur (findAny throws if no such example exists). + findAny(fromRegex("[0-9]{3}").fullmatch(false), s -> !s.matches("[0-9]{3}")); } @Test @@ -209,6 +226,20 @@ void temporalGeneratorsProduceJavaTimeTypes() { d -> d.compareTo(Duration.ofSeconds(1)) >= 0 && d.compareTo(Duration.ofSeconds(60)) <= 0); } + @Test + void boundedTemporalGenerators() { + java.time.LocalDate dateLo = java.time.LocalDate.of(2020, 2, 5); + java.time.LocalDate dateHi = java.time.LocalDate.of(2021, 3, 9); + assertAllExamples(dates().min(dateLo).max(dateHi), d -> !d.isBefore(dateLo) && !d.isAfter(dateHi)); + // Bounds are honoured at nanosecond resolution. + java.time.LocalTime timeLo = java.time.LocalTime.of(10, 30, 0, 4); + java.time.LocalTime timeHi = java.time.LocalTime.of(11, 0); + assertAllExamples(times().min(timeLo).max(timeHi), t -> !t.isBefore(timeLo) && !t.isAfter(timeHi)); + java.time.LocalDateTime lo = java.time.LocalDateTime.of(1999, 12, 31, 23, 0, 0, 1); + java.time.LocalDateTime hi = java.time.LocalDateTime.of(2000, 1, 1, 1, 0); + assertAllExamples(datetimes().min(lo).max(hi), dt -> !dt.isBefore(lo) && !dt.isAfter(hi)); + } + @Test void offsetAwareDatetimes() { // Bare offsets stay within the legal ZoneOffset range. @@ -256,15 +287,15 @@ private static boolean isTree(Object t) { if (t instanceof Integer) { return true; } - if (t instanceof Tuple2(var left, var right)) { - return isTree(left) && isTree(right); + if (t instanceof Tuple2 pair) { + return isTree(pair.value1()) && isTree(pair.value2()); } return false; } private static int depth(Object t) { - if (t instanceof Tuple2(var left, var right)) { - return 1 + Math.max(depth(left), depth(right)); + if (t instanceof Tuple2 pair) { + return 1 + Math.max(depth(pair.value1()), depth(pair.value2())); } return 0; } diff --git a/shared/src/test/java/dev/hegel/CoverageTest.java b/shared/src/test/java/dev/hegel/CoverageTest.java new file mode 100644 index 0000000..ca9137d --- /dev/null +++ b/shared/src/test/java/dev/hegel/CoverageTest.java @@ -0,0 +1,407 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Targeted tests closing remaining coverage branches. */ +class CoverageTest { + // --- Abi helpers --- + @Test + void fnv1aMatchesKnownVector() { + // FNV-1a 64-bit of the empty string is the offset basis. + assertEquals(0xcbf29ce484222325L, Label.of("")); + assertTrue(Label.COMPOSITE == Label.of("dev.hegel.composite")); + } + + // --- StringGeneratorHandle cleanup --- + @Test + void handleFreeReleasesThroughItsBinding() { + FakeLibhegel fake = new FakeLibhegel(); + new StringGeneratorHandle.Free(fake, FakeLibhegel.STRING_GEN).run(); + assertEquals(1, fake.freedStringGenerators); + } + + // --- checked exceptions from rules fail the property, wrapped with the cause preserved --- + @Test + void checkedExceptionsFromRulesFailTheProperty() { + class ThrowsChecked { + @Rule + void io(TestCase t) throws java.io.IOException { + throw new java.io.IOException("io failure"); + } + } + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0}; + TestCase tc = fakeTestCase(fake); + RuntimeException e = assertThrows(RuntimeException.class, () -> Stateful.run(new ThrowsChecked(), tc)); + assertTrue(e.getCause() instanceof java.io.IOException, String.valueOf(e.getCause())); + // The machine handle is released even when a rule fails. + assertEquals(1, fake.freedStateMachines); + } + + // --- HegelTestExtension static helpers --- + static final class Holder { + @HegelTest(seed = 5) + void seeded(TestCase tc) {} + + @HegelTest + void unseeded(TestCase tc) {} + + void plain(TestCase tc) {} + + @HegelTest( + derandomize = OptBoolean.TRUE, + phases = {Phase.GENERATE}, + suppressHealthCheck = {HealthCheck.TOO_SLOW}, + backend = Backend.URANDOM, + nondeterminismStrictness = NondeterminismStrictness.ERROR, + reportMultipleFailures = true, + printBlob = OptBoolean.TRUE, + reproduceFailure = "blob-xyz", + name = "custom") + void configured(TestCase tc) {} + + @HegelTest( + derandomize = OptBoolean.FALSE, + printBlob = OptBoolean.FALSE, + phases = {}, + database = Database.DISABLED) + void derandomFalseEmptyPhases(TestCase tc) {} + + @HegelTest(database = "/tmp/hdb") + void customDb(TestCase tc) {} + } + + @Test + void hegelTestHelpers() throws Exception { + Method seeded = Holder.class.getDeclaredMethod("seeded", TestCase.class); + Method unseeded = Holder.class.getDeclaredMethod("unseeded", TestCase.class); + Method plain = Holder.class.getDeclaredMethod("plain", TestCase.class); + + assertTrue(HegelTestExtension.isHegelTest(seeded)); + assertFalse(HegelTestExtension.isHegelTest(plain)); + assertFalse(HegelTestExtension.isHegelTest(null)); + + assertTrue(HegelTestExtension.isTestCaseParam(TestCase.class)); + assertFalse(HegelTestExtension.isTestCaseParam(String.class)); + + Settings withSeed = HegelTestExtension.settingsFrom(seeded.getAnnotation(HegelTest.class), "s"); + assertEquals(5L, withSeed.seed); + assertTrue(withSeed.hasSeed); + + // Defaults: no seed, no derandomize override, engine-default phases, default database, no + // blob replay, method name as the property name. + Settings noSeed = HegelTestExtension.settingsFrom(unseeded.getAnnotation(HegelTest.class), "u"); + assertFalse(noSeed.hasSeed); + assertNull(noSeed.derandomize); + assertNull(noSeed.phasesMask); + assertEquals(Database.Kind.UNSET, noSeed.database.kind); + assertEquals(0, noSeed.suppressMask); + assertEquals(Backend.AUTO, noSeed.backend); + assertEquals(NondeterminismStrictness.DEFAULT, noSeed.nondeterminismStrictness); + assertFalse(noSeed.reportMultipleFailures); + assertNull(noSeed.printBlob); + assertNull(noSeed.testCases); + assertNull(noSeed.reproduceFailure); + assertEquals("u", noSeed.name); + + // Fully-configured: derandomize forced on, a single explicit phase, a suppressed check, + // explicit backend, multi-failure and blob options, and a name override. + Method configured = Holder.class.getDeclaredMethod("configured", TestCase.class); + Settings c = HegelTestExtension.settingsFrom(configured.getAnnotation(HegelTest.class), "ignored"); + assertEquals(Boolean.TRUE, c.derandomize); + assertEquals(Integer.valueOf(Phase.GENERATE.bit), c.phasesMask); + assertEquals(HealthCheck.TOO_SLOW.bit, c.suppressMask); + assertEquals(Backend.URANDOM, c.backend); + assertEquals(NondeterminismStrictness.ERROR, c.nondeterminismStrictness); + assertTrue(c.reportMultipleFailures); + assertEquals(Boolean.TRUE, c.printBlob); + assertEquals("blob-xyz", c.reproduceFailure); + assertEquals("custom", c.name); + + // derandomize forced off, an explicitly empty phase set (runs nothing — distinct from the + // all-phases default), and the database disabled. + Method emptyPhases = Holder.class.getDeclaredMethod("derandomFalseEmptyPhases", TestCase.class); + Settings e = HegelTestExtension.settingsFrom(emptyPhases.getAnnotation(HegelTest.class), "e"); + assertEquals(Boolean.FALSE, e.derandomize); + assertEquals(Boolean.FALSE, e.printBlob); + assertEquals(Integer.valueOf(0), e.phasesMask); + assertEquals(Database.Kind.DISABLED, e.database.kind); + + // A custom (compile-time) database path. + Method customDb = Holder.class.getDeclaredMethod("customDb", TestCase.class); + Settings d = HegelTestExtension.settingsFrom(customDb.getAnnotation(HegelTest.class), "d"); + assertEquals(Database.Kind.PATH, d.database.kind); + assertEquals("/tmp/hdb", d.database.path); + } + + // --- generators: stale-handle rebuild and IPv6 formatting --- + @Test + void cachedStringHandlesAreRebuiltForANewBinding() { + var emailGen = Generators.emails(); + FakeLibhegel first = new FakeLibhegel(); + drawWith(first, emailGen); + FakeLibhegel second = new FakeLibhegel(); + second.stringValue = "b@c.d"; + drawWith(second, emailGen); // must rebuild rather than draw through the stale handle + FakeLibhegel third = new FakeLibhegel(); + third.stringGeneratorEmailRc = Abi.E_INVALID_ARG; + assertThrows(IllegalArgumentException.class, () -> drawWith(third, emailGen)); + } + + private static T drawWith(FakeLibhegel fake, Generator gen) { + TestCase tc = new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), false, Reporter.silent()); + return tc.draw(gen); + } + + @Test + void ipv6FormattingCompressesTheLongestZeroRun() { + assertEquals("::", fmt6(new int[] {0, 0, 0, 0, 0, 0, 0, 0})); + assertEquals("::1", fmt6(new int[] {0, 0, 0, 0, 0, 0, 0, 1})); + assertEquals("1::", fmt6(new int[] {1, 0, 0, 0, 0, 0, 0, 0})); + assertEquals("2001:db8::1", fmt6(new int[] {0x2001, 0xdb8, 0, 0, 0, 0, 0, 1})); + // A lone zero group is not compressed, and only the longest run collapses (RFC 5952). + assertEquals("1:0:1:1:1:1:1:1", fmt6(new int[] {1, 0, 1, 1, 1, 1, 1, 1})); + assertEquals("1:0:1::1:1:1", fmt6(new int[] {1, 0, 1, 0, 0, 1, 1, 1})); + assertEquals("1:2:3:4:5:6:7:8", fmt6(new int[] {1, 2, 3, 4, 5, 6, 7, 8})); + } + + private static String fmt6(int[] groups) { + byte[] b = new byte[16]; + for (int i = 0; i < 8; i++) { + b[2 * i] = (byte) (groups[i] >> 8); + b[2 * i + 1] = (byte) groups[i]; + } + try { + Method m = Class.forName("dev.hegel.generators.IpAddressGenerator") + .getDeclaredMethod("formatV6", byte[].class); + m.setAccessible(true); + return (String) m.invoke(null, (Object) b); + } catch (ReflectiveOperationException t) { + throw new AssertionError(t); + } + } + + // --- Pool bookkeeping against the fake --- + @Test + void poolTracksValuesByVariableId() { + FakeLibhegel fake = new FakeLibhegel(); + TestCase tc = new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), false, Reporter.silent()); + Pool pool = new Pool<>(tc); + assertTrue(pool.isEmpty()); + pool.add("first"); + pool.add("second"); + assertEquals(2, pool.size()); + assertEquals("first", tc.draw(pool.reusable())); // fake picks variable id 0 + assertEquals(2, pool.size()); + fake.poolGenerateValue = 1L; + assertEquals("second", tc.draw(pool.consuming())); + assertEquals(1, pool.size()); + // Empty pools reject the draw. + Pool empty = new Pool<>(tc); + assertThrows(AssumeRejected.class, () -> tc.draw(empty.reusable())); + assertThrows(AssumeRejected.class, () -> tc.draw(empty.consuming())); + } + + // --- Stateful driver against the fake --- + static final class TwoRuleMachine { + final List applied = new java.util.ArrayList<>(); + + @Rule + void alpha(TestCase tc) { + applied.add("alpha"); + } + + @Rule + void beta(TestCase tc) { + applied.add("beta"); + tc.assume(false); // exercises the rejected-rule path + } + + int sampledChecks; + int alwaysChecks; + + @Invariant + void sampled(TestCase tc) { + sampledChecks++; + } + + @Invariant(alwaysRun = true) + void unsampled(TestCase tc) { + alwaysChecks++; + } + } + + @Test + void statefulStepCountIsPassedToTheEngineAndValidated() { + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0}; + Stateful.run(new TwoRuleMachine(), fakeTestCase(fake), 7); + assertEquals(7, fake.stateMachineStepCount); + + FakeLibhegel untouched = new FakeLibhegel(); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> Stateful.run(new TwoRuleMachine(), fakeTestCase(untouched), 0)); + assertTrue(e.getMessage().contains("stepCount"), e.getMessage()); + // Rejected before anything reached the engine. + assertEquals(-1, untouched.stateMachineStepCount); + } + + @Test + void statefulDriverFollowsTheEngineRuleSequence() { + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0, 1, 0}; + TwoRuleMachine machine = new TwoRuleMachine(); + TestCase tc = fakeTestCase(fake); + Stateful.run(machine, tc); + assertEquals(List.of("alpha", "beta", "alpha"), machine.applied); + // Registered sequentially: every rule in group 0, exactly one worker. + assertEquals(List.of("alpha", "beta"), fake.stateMachineRules); + assertArrayEquals(new long[] {0, 0}, fake.stateMachineRuleGroups); + // Every rule at the default weight: the engine's all-equal NULL, not an array of ones. + assertNull(fake.stateMachineRuleWeights); + assertEquals(1, fake.stateMachineMinConcurrency); + assertEquals(1, fake.stateMachineMaxConcurrency); + assertEquals(Stateful.DEFAULT_STEP_COUNT, fake.stateMachineStepCount); + // Invariants are ordered by name and carry their always-run flags. + assertEquals(List.of("sampled", "unsampled"), fake.stateMachineInvariants); + assertArrayEquals(new boolean[] {false, true}, fake.stateMachineAlwaysCheck); + // beta's failed assumption was reported to the engine, and the machine was released. + assertEquals(1, fake.rejectedRules); + assertEquals(1, fake.freedStateMachines); + // With the fake sampling everything in: initial + 3 join points + final for both. + assertEquals(5, machine.sampledChecks); + assertEquals(5, machine.alwaysChecks); + assertEquals(List.of(0L, 1L, 0L, 1L, 0L, 1L), fake.invariantChecksAsked); + } + + static final class WeightedRules { + @Rule(weight = 0.5) + void rare(TestCase tc) {} + + @Rule + void usual(TestCase tc) {} + } + + @Test + void statefulRuleWeightsReachTheEngineInNameOrder() { + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {1}; + Stateful.run(new WeightedRules(), fakeTestCase(fake)); + assertEquals(List.of("rare", "usual"), fake.stateMachineRules); + assertArrayEquals(new double[] {0.5, 1.0}, fake.stateMachineRuleWeights); + } + + @Test + void statefulDriverHonoursTheEngineSamplingDecision() { + // When the engine samples an invariant out, only the guaranteed initial and final checks run. + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0, 0}; + fake.shouldCheckInvariant = false; + TwoRuleMachine machine = new TwoRuleMachine(); + Stateful.run(machine, fakeTestCase(fake)); + assertEquals(2, machine.sampledChecks); + assertEquals(2, machine.alwaysChecks); + assertEquals(0, fake.rejectedRules); + } + + @Test + void statefulDriverRethrowsAnEngineLevelRejection() { + // A draw inside a rule that the engine itself rejects concludes the whole case invalid: the + // rejection unwinds out of the driver instead of being reported as the rule's own + // precondition, and the machine handle is still released. + class Drawing { + @Rule + void draw(TestCase t) { + t.draw(Generators.integers()); + } + } + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0}; + fake.generateIntegerRc = Abi.E_ASSUME; + TestCase tc = fakeTestCase(fake); + assertThrows(AssumeRejected.class, () -> Stateful.run(new Drawing(), tc)); + assertEquals(0, fake.rejectedRules); + assertEquals(1, fake.freedStateMachines); + } + + @Test + void statefulDriverRejectsBadMachines() { + FakeLibhegel fake = new FakeLibhegel(); + TestCase tc = fakeTestCase(fake); + assertThrows(IllegalArgumentException.class, () -> Stateful.run(new Object(), tc)); + + class BadSignature { + @Rule + void wrong(int x) {} + } + assertThrows(IllegalArgumentException.class, () -> Stateful.run(new BadSignature(), tc)); + + class NoParameters { + @Rule + void wrong() {} + } + assertThrows(IllegalArgumentException.class, () -> Stateful.run(new NoParameters(), tc)); + + class Fine { + @Rule + void ok(TestCase t) {} + } + fake.ruleSequence = new long[] {7}; // above the rule count + HegelException high = assertThrows(HegelException.class, () -> Stateful.run(new Fine(), tc)); + assertTrue(high.getMessage().contains("out-of-range"), high.getMessage()); + + FakeLibhegel negative = new FakeLibhegel(); + negative.ruleSequence = new long[] {-5}; // negative but not the DONE sentinel + TestCase tc2 = fakeTestCase(negative); + HegelException low = assertThrows(HegelException.class, () -> Stateful.run(new Fine(), tc2)); + assertTrue(low.getMessage().contains("out-of-range"), low.getMessage()); + } + + private static TestCase fakeTestCase(FakeLibhegel fake) { + return new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), false, Reporter.silent()); + } + + @Test + void statefulRuleFailuresUnwind() { + FakeLibhegel fake = new FakeLibhegel(); + fake.ruleSequence = new long[] {0}; + class Failing { + @Rule + void explode(TestCase t) { + throw new AssertionError("rule failed"); + } + } + TestCase tc = new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), false, Reporter.silent()); + AssertionError e = assertThrows(AssertionError.class, () -> Stateful.run(new Failing(), tc)); + assertEquals("rule failed", e.getMessage()); + + class FailingInvariant { + @Rule + void ok(TestCase t) {} + + @Invariant + void broken(TestCase t) { + throw new AssertionError("invariant failed"); + } + } + FakeLibhegel fake2 = new FakeLibhegel(); + TestCase tc2 = new TestCase(new LiveDataSource(fake2, FakeLibhegel.TC), false, Reporter.silent()); + assertThrows(AssertionError.class, () -> Stateful.run(new FailingInvariant(), tc2)); + } + + @Test + void isNullChecksTheAddress() { + assertTrue(Runner.isNull(0)); + assertFalse(Runner.isNull(FakeLibhegel.TC)); + } +} diff --git a/src/test/java/dev/hegel/DerivationTest.java b/shared/src/test/java/dev/hegel/DerivationTest.java similarity index 96% rename from src/test/java/dev/hegel/DerivationTest.java rename to shared/src/test/java/dev/hegel/DerivationTest.java index cd05193..cf1e3ca 100644 --- a/src/test/java/dev/hegel/DerivationTest.java +++ b/shared/src/test/java/dev/hegel/DerivationTest.java @@ -11,8 +11,10 @@ import dev.hegel.generators.Derive; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.Test; class DerivationTest { @@ -96,6 +98,11 @@ void derivesWrapperScalarsAndBytes() { w -> w.l() != null && w.b() != null && w.d() != null && w.data() != null && w.i() != null); } + @Test + void derivesUuidScalars() { + assertAllExamples(forType(UUID.class), Objects::nonNull); + } + record Temporal( java.time.Duration d, java.time.LocalDate date, diff --git a/src/test/java/dev/hegel/EndToEndTest.java b/shared/src/test/java/dev/hegel/EndToEndTest.java similarity index 66% rename from src/test/java/dev/hegel/EndToEndTest.java rename to shared/src/test/java/dev/hegel/EndToEndTest.java index 7926fee..abe4ab5 100644 --- a/src/test/java/dev/hegel/EndToEndTest.java +++ b/shared/src/test/java/dev/hegel/EndToEndTest.java @@ -57,8 +57,29 @@ void failingPropertyThrowsAssertionErrorWithCounterexample() { @Test void reportMultipleFailuresProducesAggregateReport() { - // Opt-in multi-failure mode wraps the result in an aggregate "Hegel found ..." report - // (rather than the default direct rethrow), exercising the engine's failure-enumeration API. + // Opt-in multi-failure mode keeps searching after the first failure; two distinct bugs + // (different origins) aggregate into one "Hegel found ..." report carrying the originals + // as suppressed exceptions. + AssertionError err = assertThrows( + AssertionError.class, + () -> Hegel.test( + tc -> { + if (tc.draw(booleans())) { + throw new AssertionError("bug one"); + } + throw new IllegalStateException("bug two"); + }, + new Settings().reportMultipleFailures(true).seed(123).database(Database.disabled()))); + assertTrue(err.getMessage().contains("2 distinct failing examples"), err.getMessage()); + assertTrue(err.getMessage().contains("bug one"), err.getMessage()); + assertTrue(err.getMessage().contains("bug two"), err.getMessage()); + assertEquals(2, err.getSuppressed().length); + } + + @Test + void reportMultipleFailuresWithASingleBugRethrowsDirectly() { + // A run that finds only one distinct bug rethrows it directly — no aggregate wrapper — + // even with reportMultipleFailures enabled, matching the other Hegel frontends. AssertionError err = assertThrows( AssertionError.class, () -> Hegel.test( @@ -67,7 +88,8 @@ void reportMultipleFailuresProducesAggregateReport() { assertTrue(x <= 10, "x too big: " + x); }, new Settings().reportMultipleFailures(true).seed(123).database(Database.disabled()))); - assertTrue(err.getMessage().contains("failing example"), err.getMessage()); + assertTrue(err.getMessage().contains("x too big: 11"), err.getMessage()); + assertTrue(!err.getMessage().contains("failing example"), err.getMessage()); } @HegelTest diff --git a/src/test/java/dev/hegel/EngineEdgeTest.java b/shared/src/test/java/dev/hegel/EngineEdgeTest.java similarity index 100% rename from src/test/java/dev/hegel/EngineEdgeTest.java rename to shared/src/test/java/dev/hegel/EngineEdgeTest.java diff --git a/src/test/java/dev/hegel/EngineTest.java b/shared/src/test/java/dev/hegel/EngineTest.java similarity index 95% rename from src/test/java/dev/hegel/EngineTest.java rename to shared/src/test/java/dev/hegel/EngineTest.java index 0fda6a8..9cc1057 100644 --- a/src/test/java/dev/hegel/EngineTest.java +++ b/shared/src/test/java/dev/hegel/EngineTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; +import dev.hegel.lowlevel.Libhegel; import org.junit.jupiter.api.Test; class EngineTest { diff --git a/shared/src/test/java/dev/hegel/EnvironmentFixture.java b/shared/src/test/java/dev/hegel/EnvironmentFixture.java new file mode 100644 index 0000000..6666a96 --- /dev/null +++ b/shared/src/test/java/dev/hegel/EnvironmentFixture.java @@ -0,0 +1,69 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; + +/** + * The child-process side of {@link EnvironmentTest}. The engine reads the {@code HEGEL_*} variables + * and {@code hegel.toml} from its own process while constructing a settings handle, so what they + * produce can only be observed from a JVM launched with them. Prints one line per outcome: + * + *
      + *
    • {@code count}: {@code valid=N}, the number of cases a passing property ran under settings + * that leave the budget to the engine; + *
    • {@code count-explicit}: the same with an explicit {@code testCases(4)}; + *
    • {@code strictness}: {@code strictness=}, the nondeterminism strictness the engine + * resolved for settings that leave it unset; + *
    • {@code fail}: the printing reporter's output for a property that always fails, followed by + * {@code done}; + *
    • {@code error=} when constructing the settings failed on a malformed variable. + *
    + */ +public final class EnvironmentFixture { + private EnvironmentFixture() {} + + /** + * Entry point. + * + * @param args the mode: {@code count}, {@code count-explicit}, {@code strictness} or {@code fail} + */ + public static void main(String[] args) { + try { + switch (args[0]) { + case "count": + System.out.println("valid=" + count(new Settings())); + break; + case "count-explicit": + System.out.println("valid=" + count(new Settings().testCases(4))); + break; + case "strictness": + Settings[] effective = new Settings[1]; + Hegel.run(tc -> {}, new Settings().testCases(1), new Reporter() { + @Override + public void runStarted(Settings settings) { + effective[0] = settings; + } + }); + System.out.println("strictness=" + effective[0].nondeterminismStrictness); + break; + default: + Hegel.run( + tc -> { + tc.draw(integers(), "x"); + throw new AssertionError("always"); + }, + new Settings().testCases(5), + Reporter.printing(System.out)); + System.out.println("done"); + break; + } + } catch (IllegalArgumentException e) { + System.out.println("error=" + e.getMessage()); + } + } + + private static long count(Settings settings) { + return Hegel.run(tc -> tc.draw(integers()), settings, Reporter.silent()) + .statistics() + .valid(); + } +} diff --git a/shared/src/test/java/dev/hegel/EnvironmentTest.java b/shared/src/test/java/dev/hegel/EnvironmentTest.java new file mode 100644 index 0000000..a0e2b07 --- /dev/null +++ b/shared/src/test/java/dev/hegel/EnvironmentTest.java @@ -0,0 +1,87 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The engine resolves what {@link Settings} leaves unset from its profile ({@code hegel.toml}) and + * the {@code HEGEL_*} environment variables, and explicit settings win over both. The variables are + * read from the process environment, which a JVM cannot change for itself, so each case launches + * {@link EnvironmentFixture} in a child JVM on this test's classpath and reads its one-line verdict. + */ +class EnvironmentTest { + @TempDir + Path tmp; + + private static String fixture(String mode, Map env) throws IOException, InterruptedException { + List command = new ArrayList<>(); + command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + command.add("--enable-native-access=ALL-UNNAMED"); + command.add("-cp"); + command.add(System.getProperty("java.class.path")); + command.add(EnvironmentFixture.class.getName()); + command.add(mode); + ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(true); + // Start from a clean Hegel environment (keeping the library override this JVM runs with), + // with the database off so the child leaves no .hegel directory behind. + pb.environment().keySet().removeIf(k -> k.startsWith("HEGEL_") && !k.equals("HEGEL_LIBHEGEL_PATH")); + pb.environment().put("HEGEL_DATABASE", "disabled"); + pb.environment().putAll(env); + Process child = pb.start(); + String output = new String(child.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(child.waitFor(2, TimeUnit.MINUTES), "fixture did not finish: " + output); + return output; + } + + @Test + void hegelTestCasesSetsTheBudgetUnlessTheTestSetsItsOwn() throws Exception { + String unset = fixture("count", Map.of("HEGEL_TEST_CASES", "7")); + assertTrue(unset.contains("valid=7"), unset); + // A budget compiled into the test wins over the variable. + String explicit = fixture("count-explicit", Map.of("HEGEL_TEST_CASES", "7")); + assertTrue(explicit.contains("valid=4"), explicit); + } + + @Test + void malformedVariableIsReportedAsAnIllegalArgument() throws Exception { + String output = fixture("count", Map.of("HEGEL_TEST_CASES", "lots")); + assertTrue(output.contains("error=") && output.contains("HEGEL_TEST_CASES"), output); + } + + @Test + void hegelTomlProfileSetsTheBudget() throws Exception { + Path config = tmp.resolve("hegel.toml"); + Files.writeString(config, "[profiles.fixture]\ntest_cases = 9\n"); + String output = fixture("count", Map.of("HEGEL_CONFIG", config.toString(), "HEGEL_DEFAULT_PROFILE", "fixture")); + assertTrue(output.contains("valid=9"), output); + } + + @Test + void hegelNondeterminismStrictnessIsResolvedByTheEngine() throws Exception { + String unset = fixture("strictness", Map.of()); + assertTrue(unset.contains("strictness=QUIET"), unset); + String error = fixture("strictness", Map.of("HEGEL_NONDETERMINISM_STRICTNESS", "error")); + assertTrue(error.contains("strictness=ERROR"), error); + String malformed = fixture("strictness", Map.of("HEGEL_NONDETERMINISM_STRICTNESS", "loud")); + assertTrue(malformed.contains("error=") && malformed.contains("HEGEL_NONDETERMINISM_STRICTNESS"), malformed); + } + + @Test + void hegelPrintBlobDecidesWhetherReproducersArePrinted() throws Exception { + // The shipped profiles print a reproducer for each failure; the variable turns that off. + String printed = fixture("fail", Map.of()); + assertTrue(printed.contains("done") && printed.contains("reproduceFailure = \""), printed); + String quiet = fixture("fail", Map.of("HEGEL_PRINT_BLOB", "false")); + assertTrue(quiet.contains("done") && !quiet.contains("reproduceFailure = \""), quiet); + } +} diff --git a/shared/src/test/java/dev/hegel/FakeLibhegel.java b/shared/src/test/java/dev/hegel/FakeLibhegel.java new file mode 100644 index 0000000..030b0f3 --- /dev/null +++ b/shared/src/test/java/dev/hegel/FakeLibhegel.java @@ -0,0 +1,729 @@ +package dev.hegel; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.Libhegel; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * A configurable in-memory {@link Libhegel} for exercising error paths and runner logic without the + * native engine. Every per-case primitive's return code is a public field defaulting to {@link + * Abi#OK}; set one to a negative code to drive a specific translation path. Draws echo their lower + * bound (or a canned value) so generator plumbing can run end to end. + */ +final class FakeLibhegel implements Libhegel { + // Opaque handle sentinels (non-null addresses). + static final long SETTINGS = 0x100; + static final long RUN = 0x200; + static final long TC = 0x300; + static final long RESULT = 0x400; + static final long STRING_GEN = 0x600; + /** Clone handles are {@code CLONE_BASE + n} for the n-th clone made, so they are distinct from {@link #TC}. */ + static final long CLONE_BASE = 0x1000; + + // Test-case clones (concurrent stateful workers). + int cloneRc = Abi.OK; + boolean setWorkerFails; + /** The source handle of each clone made, in order. */ + final List clonesMade = new ArrayList<>(); + /** Clone handles released through testCaseFree, in order. */ + final List freedClones = new ArrayList<>(); + /** The worker index each handle was attributed to. */ + final Map workersSet = new LinkedHashMap<>(); + + String lastError = "fake error"; + String version = "0.0.0-fake"; + + // Run-loop control. + int caseCount = 1; // how many test cases nextTestCase yields + private int casesServed; + boolean runStartFails; + boolean nextTestCaseFails; + int runStatus = Abi.RUN_STATUS_PASSED; + String runError = "run error"; + final List failureBlobs = new ArrayList<>(); // one entry per distinct failure + final List failureCaveats = new ArrayList<>(); // parallel to failureBlobs; missing = null + // The origin of each distinct failure. Like the engine, the fake echoes back the origins marked + // interesting, distinct and in first-seen order; an explicit entry here overrides that. + final List failureOrigins = new ArrayList<>(); + int fromBlobRc = Abi.OK; + final List replayedBlobs = new ArrayList<>(); + String startedBlob; // what runStartBlob received; null = the run explored + Consumer output; // the callback runStart / runStartBlob registered + // The engine's capture stamp per served case: the i-th case is stamped when captureSequence + // has an i-th entry and it is true; past the end of the sequence every case is stamped, so + // the default stamps everything. + boolean[] captureSequence = {}; + + // Recorded outcomes and teardown. + final List markedStatuses = new ArrayList<>(); + final List markedOrigins = new ArrayList<>(); + int markCompleteRc = Abi.OK; + int freedTestCases; + int freedStringGenerators; + boolean runFreed; + boolean runResultFreed; + boolean settingsFreed; + + // Captured settings. + int phasesMask = -1; // -1 means settingsPhases was never called + int suppressMask = -1; + Integer backendCode; + Long testCases; + String databasePath = "unset"; + String databaseKey; + Boolean derandomize; + + // Per-primitive return codes and canned values. + int generateBooleanRc = Abi.OK; + boolean booleanValue; + int generateIntegerRc = Abi.OK; + Long integerValue; // null = echo the min bound + Long integerMin; + Long integerMax; + int generateFloatRc = Abi.OK; + Double floatValue; // null = 0.0 + int generateBytesRc = Abi.OK; + byte[] bytesValue = {1, 2}; + Long bytesMinSize; + Long bytesMaxSize; + int generateStringRc = Abi.OK; + String stringValue = "s"; + int generateDateRc = Abi.OK; + int generateTimeRc = Abi.OK; + int generateDatetimeRc = Abi.OK; + int generateUuidRc = Abi.OK; + byte[] uuidBytes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; + int generateIpv4Rc = Abi.OK; + byte[] ipv4Bytes = {127, 0, 0, 1}; + int generateIpv6Rc = Abi.OK; + byte[] ipv6Bytes = new byte[16]; + + int stringGeneratorTextRc = Abi.OK; + int stringGeneratorRegexRc = Abi.OK; + int stringGeneratorEmailRc = Abi.OK; + int stringGeneratorUrlRc = Abi.OK; + int stringGeneratorDomainRc = Abi.OK; + // Captured text-generator configuration, for builder-behaviour assertions. + Long textMinSize; + Long textMaxSize; + Long textMinCodepoint; + Long textMaxCodepoint; + List textCategories; + List textExcludeCategories; + String textIncludeCharacters; + String textExcludeCharacters; + Long domainMaxLength; + + int startSpanRc = Abi.OK; + int stopSpanRc = Abi.OK; + int stoppedSpans; + final List startedSpans = new ArrayList<>(); + int newCollectionRc = Abi.OK; + long collectionId = 7; + Long collectionMinSize; + Long collectionMaxSize; + int collectionMoreRc = Abi.OK; + boolean[] moreSequence = {false}; + private int moreIndex; + int collectionRejectRc = Abi.OK; + int targetRc = Abi.OK; + + int newPoolRc = Abi.OK; + long poolId = 3; + int poolAddRc = Abi.OK; + private long nextVariableId; + int poolGenerateRc = Abi.OK; + Long poolGenerateValue; // null = the first added variable id (0) + // State machines. The fake plays the engine's round protocol. Sequentially, each round + // (next_group) hands out exactly one rule from `ruleSequence` (next_rule), then the join + // point; once the sequence is exhausted next_group reports DONE. Concurrently, set + // `concurrentRounds`: round r hands worker w the rules `concurrentRounds[r][w]` (a missing or + // empty worker entry means that worker's round is over at once), and `stateMachineConcurrency` + // says how many workers new_state_machine reports. The protocol methods are synchronized + // because concurrent workers call them at the same time. + int newStateMachineRc = Abi.OK; + long stateMachineId = 5; + List stateMachineRules; + long[] stateMachineRuleGroups; + double[] stateMachineRuleWeights = {-1}; // what new_state_machine received; null = equal weights + List stateMachineInvariants; + boolean[] stateMachineAlwaysCheck; + long stateMachineMinConcurrency = -1; + long stateMachineMaxConcurrency = -1; + long stateMachineStepCount = -1; + long stateMachineConcurrency = 1; // what new_state_machine writes to out_concurrency + int stateMachineNextGroupRc = Abi.OK; + long stateMachineGroupId = 0; + int stateMachineNextRuleRc = Abi.OK; + long[] ruleSequence = {}; // the rule index handed out each round + private int ruleIndex; + long[][][] concurrentRounds; // null = the sequential protocol over ruleSequence + private int roundsServed; + private long[][] currentRound; // null = no round open + private int[] positions; // per worker, how far into its queue for the current round + /** The worker index of every next_rule call, in call order. */ + final List nextRuleWorkers = new ArrayList<>(); + + int stateMachineRuleRejectedRc = Abi.OK; + int rejectedRules; + /** The worker index of every rule_rejected call, in call order. */ + final List rejectedWorkers = new ArrayList<>(); + + int stateMachineShouldCheckInvariantRc = Abi.OK; + boolean shouldCheckInvariant = true; // the sampling decision for every invariant + final List invariantChecksAsked = new ArrayList<>(); + int freedStateMachines; + + // What hegel_settings_new hands back: the resolved profile's values, as the getters report them. + int settingsNewRc = Abi.OK; + long resolvedTestCases = 100; + boolean resolvedPrintBlob = false; + int resolvedStrictness = Abi.NONDETERMINISM_QUIET; + Boolean printBlob; // what the setter received; null = never set + Integer strictness; // what the setter received; null = never set + + @Override + public int settingsNew(long[] out) { + if (settingsNewRc == Abi.OK) { + out[0] = SETTINGS; + } + return settingsNewRc; + } + + @Override + public long settingsGetTestCases(long s) { + return testCases == null ? resolvedTestCases : testCases; + } + + @Override + public void settingsPrintBlob(long s, boolean yes) { + printBlob = yes; + } + + @Override + public boolean settingsGetPrintBlob(long s) { + return printBlob == null ? resolvedPrintBlob : printBlob; + } + + @Override + public void settingsNondeterminismStrictness(long s, int strictness) { + this.strictness = strictness; + } + + @Override + public int settingsGetNondeterminismStrictness(long s) { + return strictness == null ? resolvedStrictness : strictness; + } + + @Override + public void settingsFree(long s) { + settingsFreed = true; + } + + @Override + public void settingsBackend(long s, int backend) { + backendCode = backend; + } + + @Override + public void settingsTestCases(long s, long n) { + testCases = n; + } + + @Override + public void settingsVerbosity(long s, int v) {} + + @Override + public void settingsSeed(long s, long seed, boolean hasSeed) {} + + @Override + public void settingsDerandomize(long s, boolean derandomize) { + this.derandomize = derandomize; + } + + @Override + public void settingsReportMultipleFailures(long s, boolean yes) {} + + @Override + public void settingsDatabase(long s, String path) { + databasePath = path; + } + + @Override + public void settingsDatabaseKey(long s, String key) { + databaseKey = key; + } + + @Override + public void settingsPhases(long s, int mask) { + phasesMask = mask; + } + + @Override + public void settingsSuppressHealthCheck(long s, int mask) { + suppressMask = mask; + } + + @Override + public long runStart(long settings, Consumer output) { + if (runStartFails) { + throw new HegelException("hegel_run_start failed: " + lastError); + } + this.output = output; + return RUN; + } + + @Override + public long runStartBlob(long settings, String blob, Consumer output) { + if (runStartFails) { + throw new HegelException("hegel_run_start_blob failed: " + lastError); + } + startedBlob = blob; + this.output = output; + return RUN; + } + + @Override + public long nextTestCase(long run) { + if (nextTestCaseFails) { + throw new HegelException("hegel_next_test_case failed: " + lastError); + } + if (casesServed >= caseCount) { + return 0; + } + casesServed++; + return TC; + } + + @Override + public boolean testCaseShouldCapture(long tc) { + int index = casesServed - 1; + return index >= captureSequence.length || captureSequence[index]; + } + + @Override + public long runResult(long run) { + return RESULT; + } + + @Override + public void runResultFree(long result) { + runResultFreed = true; + } + + @Override + public void runFree(long run) { + runFreed = true; + } + + @Override + public int testCaseFromBlob(long settings, String blob, Consumer output, long[] out) { + if (fromBlobRc == Abi.OK) { + replayedBlobs.add(blob); + out[0] = TC; + } + return fromBlobRc; + } + + @Override + public void testCaseFree(long tc) { + freedTestCases++; + if (tc != TC) { + freedClones.add(tc); + } + } + + @Override + public int testCaseClone(long tc, long[] out) { + if (cloneRc == Abi.OK) { + out[0] = CLONE_BASE + clonesMade.size(); + clonesMade.add(tc); + } + return cloneRc; + } + + @Override + public void testCaseSetWorker(long tc, long workerIndex) { + if (setWorkerFails) { + throw new HegelException("hegel_test_case_set_worker failed: " + lastError); + } + workersSet.put(tc, workerIndex); + } + + @Override + public int generateBoolean(long tc, double p, boolean[] out) { + if (generateBooleanRc == Abi.OK) { + out[0] = booleanValue; + } + return generateBooleanRc; + } + + @Override + public int generateInteger(long tc, long min, long max, long[] out) { + if (generateIntegerRc == Abi.OK) { + integerMin = min; + integerMax = max; + out[0] = integerValue == null ? min : integerValue; + } + return generateIntegerRc; + } + + @Override + public int generateFloat( + long tc, + int width, + double min, + double max, + boolean allowNan, + boolean allowInfinity, + boolean excludeMin, + boolean excludeMax, + double smallestNonzeroMagnitude, + double[] out) { + if (generateFloatRc == Abi.OK) { + out[0] = floatValue == null ? 0.0 : floatValue; + } + return generateFloatRc; + } + + @Override + public int generateBytes(long tc, long minSize, long maxSize, byte[][] out) { + if (generateBytesRc == Abi.OK) { + bytesMinSize = minSize; + bytesMaxSize = maxSize; + out[0] = bytesValue; + } + return generateBytesRc; + } + + @Override + public int generateString(long tc, long generator, String[] out) { + if (generateStringRc == Abi.OK) { + out[0] = stringValue; + } + return generateStringRc; + } + + @Override + public int generateDate(long tc, LocalDate min, LocalDate max, LocalDate[] out) { + if (generateDateRc == Abi.OK) { + out[0] = min; + } + return generateDateRc; + } + + @Override + public int generateTime(long tc, LocalTime min, LocalTime max, LocalTime[] out) { + if (generateTimeRc == Abi.OK) { + out[0] = min; + } + return generateTimeRc; + } + + @Override + public int generateDatetime(long tc, LocalDateTime min, LocalDateTime max, LocalDateTime[] out) { + if (generateDatetimeRc == Abi.OK) { + out[0] = min; + } + return generateDatetimeRc; + } + + @Override + public int generateUuid(long tc, int version, boolean hasVersion, byte[] out16) { + if (generateUuidRc == Abi.OK) { + System.arraycopy(uuidBytes, 0, out16, 0, 16); + } + return generateUuidRc; + } + + @Override + public int generateIpv4(long tc, byte[] out4) { + if (generateIpv4Rc == Abi.OK) { + System.arraycopy(ipv4Bytes, 0, out4, 0, 4); + } + return generateIpv4Rc; + } + + @Override + public int generateIpv6(long tc, byte[] out16) { + if (generateIpv6Rc == Abi.OK) { + System.arraycopy(ipv6Bytes, 0, out16, 0, 16); + } + return generateIpv6Rc; + } + + @Override + public int stringGeneratorText( + long minSize, + long maxSize, + String codec, + long minCodepoint, + long maxCodepoint, + List categories, + List excludeCategories, + String includeCharacters, + String excludeCharacters, + long[] out) { + if (stringGeneratorTextRc == Abi.OK) { + textMinSize = minSize; + textMaxSize = maxSize; + textMinCodepoint = minCodepoint; + textMaxCodepoint = maxCodepoint; + textCategories = categories; + textExcludeCategories = excludeCategories; + textIncludeCharacters = includeCharacters; + textExcludeCharacters = excludeCharacters; + out[0] = STRING_GEN; + } + return stringGeneratorTextRc; + } + + @Override + public int stringGeneratorRegex(String pattern, boolean fullmatch, long alphabet, long[] out) { + if (stringGeneratorRegexRc == Abi.OK) { + out[0] = STRING_GEN; + } + return stringGeneratorRegexRc; + } + + @Override + public int stringGeneratorEmail(long[] out) { + if (stringGeneratorEmailRc == Abi.OK) { + out[0] = STRING_GEN; + } + return stringGeneratorEmailRc; + } + + @Override + public int stringGeneratorUrl(long[] out) { + if (stringGeneratorUrlRc == Abi.OK) { + out[0] = STRING_GEN; + } + return stringGeneratorUrlRc; + } + + @Override + public int stringGeneratorDomain(long maxLength, long[] out) { + if (stringGeneratorDomainRc == Abi.OK) { + domainMaxLength = maxLength; + out[0] = STRING_GEN; + } + return stringGeneratorDomainRc; + } + + @Override + public void stringGeneratorFree(long generator) { + freedStringGenerators++; + } + + @Override + public int startSpan(long tc, long label) { + if (startSpanRc == Abi.OK) { + startedSpans.add(label); + } + return startSpanRc; + } + + @Override + public int stopSpan(long tc, boolean discard) { + stoppedSpans++; + return stopSpanRc; + } + + @Override + public int newCollection(long tc, long minSize, long maxSize, long[] outId) { + if (newCollectionRc == Abi.OK) { + collectionMinSize = minSize; + collectionMaxSize = maxSize; + outId[0] = collectionId; + } + return newCollectionRc; + } + + @Override + public int collectionMore(long tc, long id, boolean[] outMore) { + if (collectionMoreRc == Abi.OK) { + outMore[0] = moreIndex < moreSequence.length && moreSequence[moreIndex++]; + } + return collectionMoreRc; + } + + @Override + public int collectionReject(long tc, long id, String why) { + return collectionRejectRc; + } + + @Override + public int newPool(long tc, long[] outId) { + if (newPoolRc == Abi.OK) { + outId[0] = poolId; + } + return newPoolRc; + } + + @Override + public int poolAdd(long tc, long poolId, long[] outVariableId) { + if (poolAddRc == Abi.OK) { + outVariableId[0] = nextVariableId++; + } + return poolAddRc; + } + + @Override + public int poolGenerate(long tc, long poolId, boolean consume, long[] outVariableId) { + if (poolGenerateRc == Abi.OK) { + outVariableId[0] = poolGenerateValue == null ? 0 : poolGenerateValue; + } + return poolGenerateRc; + } + + @Override + public int newStateMachine( + long tc, + List ruleNames, + long[] ruleGroups, + double[] ruleWeights, + List invariantNames, + boolean[] invariantAlwaysCheck, + long minConcurrency, + long maxConcurrency, + long stepCount, + long[] outId, + long[] outConcurrency) { + if (newStateMachineRc == Abi.OK) { + stateMachineRules = ruleNames; + stateMachineRuleGroups = ruleGroups; + stateMachineRuleWeights = ruleWeights; + stateMachineInvariants = invariantNames; + stateMachineAlwaysCheck = invariantAlwaysCheck; + stateMachineMinConcurrency = minConcurrency; + stateMachineMaxConcurrency = maxConcurrency; + stateMachineStepCount = stepCount; + outId[0] = stateMachineId; + outConcurrency[0] = stateMachineConcurrency; + } + return newStateMachineRc; + } + + @Override + public synchronized int stateMachineNextGroup(long tc, long stateMachineId, long[] outGroupId) { + if (stateMachineNextGroupRc == Abi.OK) { + if (concurrentRounds != null) { + currentRound = roundsServed < concurrentRounds.length ? concurrentRounds[roundsServed++] : null; + } else { + // Sequential: one rule for worker 0 per round. + currentRound = ruleIndex < ruleSequence.length ? new long[][] {{ruleSequence[ruleIndex++]}} : null; + } + positions = new int[(int) stateMachineConcurrency]; + outGroupId[0] = currentRound != null ? stateMachineGroupId : Abi.STATE_MACHINE_DONE; + } + return stateMachineNextGroupRc; + } + + @Override + public synchronized int stateMachineNextRule(long tc, long stateMachineId, long workerIndex, long[] outRuleIndex) { + if (stateMachineNextRuleRc == Abi.OK) { + nextRuleWorkers.add(workerIndex); + int w = (int) workerIndex; + long[] queue = currentRound != null && w < currentRound.length ? currentRound[w] : new long[0]; + if (positions[w] < queue.length) { + outRuleIndex[0] = queue[positions[w]++]; + } else { + outRuleIndex[0] = Abi.STATE_MACHINE_DONE; + } + } + return stateMachineNextRuleRc; + } + + @Override + public synchronized int stateMachineRuleRejected(long tc, long stateMachineId, long workerIndex) { + if (stateMachineRuleRejectedRc == Abi.OK) { + rejectedRules++; + rejectedWorkers.add(workerIndex); + } + return stateMachineRuleRejectedRc; + } + + @Override + public int stateMachineShouldCheckInvariant( + long tc, long stateMachineId, long invariantIndex, boolean[] outShouldCheck) { + if (stateMachineShouldCheckInvariantRc == Abi.OK) { + invariantChecksAsked.add(invariantIndex); + outShouldCheck[0] = shouldCheckInvariant; + } + return stateMachineShouldCheckInvariantRc; + } + + @Override + public void stateMachineFree(long stateMachineId) { + freedStateMachines++; + } + + @Override + public int target(long tc, double value, String label) { + return targetRc; + } + + @Override + public int markComplete(long tc, int status, String origin) { + markedStatuses.add(status); + markedOrigins.add(origin); + return markCompleteRc; + } + + @Override + public int runResultStatus(long result) { + return runStatus; + } + + @Override + public String runResultError(long result) { + return runError; + } + + @Override + public long runResultFailureCount(long result) { + return failureBlobs.size(); + } + + @Override + public String failureBlob(long result, long index) { + return failureBlobs.get((int) index); + } + + @Override + public String failureOrigin(long result, long index) { + if (index < failureOrigins.size()) { + return failureOrigins.get((int) index); + } + List distinct = new ArrayList<>(); + for (String origin : markedOrigins) { + if (origin != null && !distinct.contains(origin)) { + distinct.add(origin); + } + } + return distinct.get((int) index); + } + + @Override + public String failureCaveat(long result, long index) { + return index < failureCaveats.size() ? failureCaveats.get((int) index) : null; + } + + @Override + public String lastErrorMessage() { + return lastError; + } + + @Override + public String version() { + return version; + } +} diff --git a/src/test/java/dev/hegel/FloatVsDoubleTest.java b/shared/src/test/java/dev/hegel/FloatVsDoubleTest.java similarity index 78% rename from src/test/java/dev/hegel/FloatVsDoubleTest.java rename to shared/src/test/java/dev/hegel/FloatVsDoubleTest.java index faa87d9..ea8ea24 100644 --- a/src/test/java/dev/hegel/FloatVsDoubleTest.java +++ b/shared/src/test/java/dev/hegel/FloatVsDoubleTest.java @@ -10,15 +10,9 @@ /** * {@code floats()} and {@code doubles()} are distinct: the former is a true 32-bit {@code float} - * (IEEE single, schema width 32), the latter a 64-bit {@code double} (width 64). + * (IEEE single, draw width 32), the latter a 64-bit {@code double} (width 64). */ class FloatVsDoubleTest { - @Test - void schemasCarryTheRightWidth() { - assertEquals(32, floats().asBasic().schema.get("width").AsInt32()); - assertEquals(64, doubles().asBasic().schema.get("width").AsInt32()); - } - @HegelTest(testCases = 20, database = Database.DISABLED) void drawsAreTheRightJavaType(TestCase tc) { assertInstanceOf(Float.class, tc.draw(floats())); diff --git a/shared/src/test/java/dev/hegel/FluentBuilderTest.java b/shared/src/test/java/dev/hegel/FluentBuilderTest.java new file mode 100644 index 0000000..69b278c --- /dev/null +++ b/shared/src/test/java/dev/hegel/FluentBuilderTest.java @@ -0,0 +1,108 @@ +package dev.hegel; + +import static dev.hegel.Generators.binary; +import static dev.hegel.Generators.integers; +import static dev.hegel.Generators.lists; +import static dev.hegel.Generators.longs; +import static dev.hegel.Generators.text; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.hegel.lowlevel.Abi; +import org.junit.jupiter.api.Test; + +/** + * Bounds and sizes are configured exclusively through fluent builder methods. Each bound can be set + * independently, and an unset bound stays at its full-range default. Equivalence is checked at the + * engine boundary: the parameters a draw hands the (fake) engine. + */ +class FluentBuilderTest { + private FakeLibhegel fake; + + private TestCase testCase() { + fake = new FakeLibhegel(); + return new TestCase(new LiveDataSource(fake, FakeLibhegel.TC), false, Reporter.silent()); + } + + @Test + void integersDefaultIsFullRange() { + testCase().draw(integers()); + assertEquals(Integer.MIN_VALUE, fake.integerMin); + assertEquals(Integer.MAX_VALUE, fake.integerMax); + } + + @Test + void integersIndependentBounds() { + // Setting one bound leaves the other at the full-range default. + testCase().draw(integers().max(5)); + assertEquals(Integer.MIN_VALUE, fake.integerMin); + assertEquals(5, fake.integerMax); + testCase().draw(integers().min(-3)); + assertEquals(-3, fake.integerMin); + assertEquals(Integer.MAX_VALUE, fake.integerMax); + } + + @Test + void longsIndependentBounds() { + testCase().draw(longs().max(5)); + assertEquals(Long.MIN_VALUE, fake.integerMin); + assertEquals(5, fake.integerMax); + testCase().draw(longs().min(-3)); + assertEquals(-3, fake.integerMin); + assertEquals(Long.MAX_VALUE, fake.integerMax); + } + + @Test + void binarySizeDefaults() { + // With no explicit maxSize the length is capped at the default (100), shifted up when the + // minimum exceeds it. + testCase().draw(binary().minSize(2)); + assertEquals(2, fake.bytesMinSize); + assertEquals(100, fake.bytesMaxSize); + testCase().draw(binary().minSize(150)); + assertEquals(150, fake.bytesMinSize); + assertEquals(250, fake.bytesMaxSize); + testCase().draw(binary().maxSize(7)); + assertEquals(0, fake.bytesMinSize); + assertEquals(7, fake.bytesMaxSize); + } + + @Test + void textSizeDefaultsAndCharacterConfig() { + testCase().draw(text().minSize(3)); + assertEquals(3, fake.textMinSize); + assertEquals(100, fake.textMaxSize); + // Surrogates are excluded by default. + assertEquals(java.util.List.of("Cs"), fake.textExcludeCategories); + assertEquals(null, fake.textCategories); + + testCase().draw(text().minSize(200)); + assertEquals(200, fake.textMinSize); + assertEquals(300, fake.textMaxSize); + + testCase() + .draw(text().codepoints('a', 'z') + .categories("Nd") + .includeCharacters("x") + .excludeCharacters("y")); + assertEquals((long) 'a', fake.textMinCodepoint); + assertEquals((long) 'z', fake.textMaxCodepoint); + assertEquals(java.util.List.of("Nd"), fake.textCategories); + assertEquals(null, fake.textExcludeCategories); + assertEquals("x", fake.textIncludeCharacters); + assertEquals("y", fake.textExcludeCharacters); + + // An explicit exclusion keeps Cs appended exactly once. + testCase().draw(text().excludeCategories("Cc")); + assertEquals(java.util.List.of("Cc", "Cs"), fake.textExcludeCategories); + testCase().draw(text().excludeCategories("Cs")); + assertEquals(java.util.List.of("Cs"), fake.textExcludeCategories); + } + + @Test + void listsMinOnlyStaysUnbounded() { + // Setting only a minimum leaves the collection unbounded above (UINT64_MAX sentinel). + testCase().draw(lists(integers()).minSize(1)); + assertEquals(1, fake.collectionMinSize); + assertEquals(Abi.UNBOUNDED, fake.collectionMaxSize); + } +} diff --git a/src/test/java/dev/hegel/GenerationQualityTest.java b/shared/src/test/java/dev/hegel/GenerationQualityTest.java similarity index 100% rename from src/test/java/dev/hegel/GenerationQualityTest.java rename to shared/src/test/java/dev/hegel/GenerationQualityTest.java diff --git a/src/test/java/dev/hegel/GeneratorSmokeTest.java b/shared/src/test/java/dev/hegel/GeneratorSmokeTest.java similarity index 97% rename from src/test/java/dev/hegel/GeneratorSmokeTest.java rename to shared/src/test/java/dev/hegel/GeneratorSmokeTest.java index aeae7b2..49bcc5a 100644 --- a/src/test/java/dev/hegel/GeneratorSmokeTest.java +++ b/shared/src/test/java/dev/hegel/GeneratorSmokeTest.java @@ -58,7 +58,8 @@ void setsAreUnique(TestCase tc) { @HegelTest void mapsBasicAndNonBasic(TestCase tc) { - Map m = tc.draw(maps(integers().min(0).max(3), text().maxSize(3))); + Map m = + tc.draw(maps(integers().min(0).max(3), text().maxSize(3)).maxSize(4)); assertTrue(m.size() <= 4); // non-basic key via filter Map m2 = tc.draw(maps( diff --git a/src/test/java/dev/hegel/GeneratorValidationTest.java b/shared/src/test/java/dev/hegel/GeneratorValidationTest.java similarity index 51% rename from src/test/java/dev/hegel/GeneratorValidationTest.java rename to shared/src/test/java/dev/hegel/GeneratorValidationTest.java index 4d55efa..3ebdf2f 100644 --- a/src/test/java/dev/hegel/GeneratorValidationTest.java +++ b/shared/src/test/java/dev/hegel/GeneratorValidationTest.java @@ -1,6 +1,9 @@ package dev.hegel; import static dev.hegel.Generators.binary; +import static dev.hegel.Generators.dates; +import static dev.hegel.Generators.datetimes; +import static dev.hegel.Generators.domains; import static dev.hegel.Generators.doubles; import static dev.hegel.Generators.durations; import static dev.hegel.Generators.floats; @@ -12,9 +15,13 @@ 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.zoneOffsets; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import org.junit.jupiter.api.Test; @@ -53,15 +60,72 @@ void doubleConflicts() { doubles().max(0).allowInfinity(true); } + @Test + void floatExclusiveBoundEdges() { + assertThrows(IllegalArgumentException.class, () -> doubles().excludeMin(true)); + assertThrows(IllegalArgumentException.class, () -> doubles().excludeMax(true)); + assertThrows( + IllegalArgumentException.class, + () -> doubles().min(1.0).max(1.0).excludeMin(true)); + assertThrows( + IllegalArgumentException.class, + () -> doubles().min(1.0).max(1.0).excludeMax(true)); + assertThrows( + IllegalArgumentException.class, + () -> doubles().min(Double.POSITIVE_INFINITY).excludeMin(true)); + assertThrows( + IllegalArgumentException.class, + () -> doubles().max(Double.NEGATIVE_INFINITY).excludeMax(true)); + assertThrows(IllegalArgumentException.class, () -> doubles().min(0.0).max(-0.0)); + // The mirror direction (-0.0 to +0.0) is legal. + doubles().min(-0.0).max(0.0); + } + + @Test + void subnormalConflicts() { + // allowSubnormal(true) requires bounds that admit at least one subnormal. + assertThrows( + IllegalArgumentException.class, + () -> doubles().min(1.0).max(2.0).allowSubnormal(true)); + assertThrows( + IllegalArgumentException.class, + () -> doubles().min(-2.0).max(-1.0).allowSubnormal(true)); + // allowSubnormal(false) with a range of nothing but subnormals leaves no values. + assertThrows( + IllegalArgumentException.class, + () -> doubles().min(Double.MIN_VALUE).max(Double.MIN_VALUE * 4).allowSubnormal(false)); + // A range containing zero is fine without subnormals. + doubles().min(-1.0).max(1.0).allowSubnormal(false); + // Same-valued bounds allow subnormals only when the value is one. + floats().min(Float.MIN_VALUE).max(Float.MIN_VALUE).allowSubnormal(true); + } + @Test void textConstraints() { assertThrows(IllegalArgumentException.class, () -> text().minSize(-1)); assertThrows(IllegalArgumentException.class, () -> text().minSize(5).maxSize(2)); assertThrows(IllegalArgumentException.class, () -> text().codepoints(10, 1)); + assertThrows(IllegalArgumentException.class, () -> text().categories("Cs")); + assertThrows(IllegalArgumentException.class, () -> text().categories("C")); + } + + @Test + void temporalBounds() { + assertThrows( + IllegalArgumentException.class, + () -> dates().min(LocalDate.of(2020, 1, 2)).max(LocalDate.of(2020, 1, 1))); + assertThrows(IllegalArgumentException.class, () -> dates().min(LocalDate.of(-1_000_000, 1, 1))); + assertThrows(IllegalArgumentException.class, () -> dates().max(LocalDate.of(1_000_000, 1, 1))); assertThrows( - IllegalArgumentException.class, () -> text().categories("Cs").asBasic()); + IllegalArgumentException.class, + () -> times().min(LocalTime.of(2, 0)).max(LocalTime.of(1, 0))); + // Bounds are nanosecond-exact, so the last instant of the day is a valid (one-value) range. + times().min(LocalTime.MAX); assertThrows( - IllegalArgumentException.class, () -> text().categories("C").asBasic()); + IllegalArgumentException.class, + () -> datetimes().min(LocalDateTime.of(2020, 1, 1, 0, 0)).max(LocalDateTime.of(2019, 1, 1, 0, 0))); + assertThrows(IllegalArgumentException.class, () -> datetimes().max(LocalDateTime.of(1_000_000, 1, 1, 0, 0))); + assertThrows(IllegalArgumentException.class, () -> datetimes().min(LocalDateTime.of(-1_000_000, 1, 1, 0, 0))); } @Test @@ -79,6 +143,20 @@ void zoneOffsetBounds() { () -> zoneOffsets().min(java.time.ZoneOffset.ofHours(2)).max(java.time.ZoneOffset.ofHours(1))); } + @Test + void domainLengthBounds() { + assertThrows(IllegalArgumentException.class, () -> domains().maxLength(3)); + assertThrows(IllegalArgumentException.class, () -> domains().maxLength(256)); + domains().maxLength(4); + domains().maxLength(255); + } + + @Test + void uuidVersionBounds() { + assertThrows(IllegalArgumentException.class, () -> Generators.uuids().version(0)); + assertThrows(IllegalArgumentException.class, () -> Generators.uuids().version(6)); + } + @Test void collectionBounds() { assertThrows(IllegalArgumentException.class, () -> binary().minSize(5).maxSize(3)); diff --git a/src/test/java/dev/hegel/HegelTestAnnotationTest.java b/shared/src/test/java/dev/hegel/HegelTestAnnotationTest.java similarity index 100% rename from src/test/java/dev/hegel/HegelTestAnnotationTest.java rename to shared/src/test/java/dev/hegel/HegelTestAnnotationTest.java diff --git a/shared/src/test/java/dev/hegel/LabelTest.java b/shared/src/test/java/dev/hegel/LabelTest.java new file mode 100644 index 0000000..4614a4b --- /dev/null +++ b/shared/src/test/java/dev/hegel/LabelTest.java @@ -0,0 +1,48 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +/** + * {@link Label#of} and {@link Label#combine} must agree with libhegel's {@code + * hegel_label_from_name} / {@code hegel_label_combine}: the 64-bit FNV-1a hash of the name's UTF-8 + * bytes, and FNV-1a over the little-endian bytes of each label in turn. The expected values were + * computed independently from that definition. + */ +class LabelTest { + private static final long LIST = 0x588C222984E69C23L; + private static final long INTEGER = -0x57664BBC5FB7D3A5L; + + @Test + void ofIsTheFnv1aHashOfTheName() { + assertEquals(LIST, Label.of("dev.hegel.list")); + assertEquals(INTEGER, Label.of("dev.hegel.integer")); + assertEquals(LIST, Label.LIST); + } + + @Test + void combineHashesTheLabelsInOrder() { + assertEquals(-0x6BA08FBF26A1271AL, Label.combine(LIST, INTEGER)); + assertEquals(-0x717EA0EE91B61276L, Label.combine(INTEGER, LIST)); + assertEquals(0x47D7283BA12CCBC7L, Label.combine(LIST)); + assertEquals(-0x340D631B7BDDDCDBL, Label.combine()); + // A single label does not combine to itself, so lists(x) never collides with x. + assertNotEquals(LIST, Label.combine(LIST)); + } + + @Test + void frontendConstantsAreDistinct() { + long[] all = { + Label.LIST, Label.LIST_ELEMENT, Label.SET, Label.SET_ELEMENT, Label.MAP, Label.MAP_ENTRY, + Label.TUPLE, Label.ONE_OF, Label.OPTIONAL, Label.FIXED_DICT, Label.FLAT_MAP, Label.FILTER, + Label.MAPPED, Label.SAMPLED_FROM, Label.ENUM_VARIANT, Label.STATEFUL_RULE, Label.COMPOSITE, + }; + for (int i = 0; i < all.length; i++) { + for (int j = i + 1; j < all.length; j++) { + assertNotEquals(all[i], all[j], i + " vs " + j); + } + } + } +} diff --git a/shared/src/test/java/dev/hegel/LibraryApiTest.java b/shared/src/test/java/dev/hegel/LibraryApiTest.java new file mode 100644 index 0000000..8fa6441 --- /dev/null +++ b/shared/src/test/java/dev/hegel/LibraryApiTest.java @@ -0,0 +1,114 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * The library-facing surface against the real engine: {@link Hegel#run} reports instead of + * throwing, final replays are identifiable, and spans work from a foreign generator. + */ +class LibraryApiTest { + private static final Settings QUIET = + new Settings().database(Database.disabled()).seed(123); + + @Test + void runReportsTheShrunkCounterexample() { + AtomicInteger finals = new AtomicInteger(); + RunReport report = Hegel.run( + tc -> { + int x = tc.draw(integers().min(0).max(1000), "x"); + tc.note("observed x=" + x); + if (tc.isFinal()) { + finals.incrementAndGet(); + } + assertTrue(x <= 10, "x was too big: " + x); + }, + QUIET, + Reporter.silent()); + assertEquals(RunStatus.FAILED, report.status()); + assertEquals(1, report.failures().size()); + Failure f = report.failures().get(0); + assertEquals(Map.of("x", 11), f.draws()); + assertEquals(List.of("observed x=11"), f.notes()); + assertTrue(f.exception() instanceof AssertionError); + assertTrue(f.exception().getMessage().contains("x was too big: 11")); + assertTrue(f.origin().startsWith("AssertionFailedError at "), f.origin()); + assertTrue(f.reproduceBlob().isPresent()); + assertFalse(f.nondeterministic()); + assertEquals(Optional.empty(), f.caveat()); + // The engine stamped at least the final replay for capture, and the run counted more than + // the stamped cases. + assertTrue(finals.get() >= 1, "stamped cases: " + finals.get()); + assertTrue(report.statistics().interesting() >= 2, report.statistics().toString()); + assertTrue(report.statistics().total() > report.statistics().interesting()); + } + + @Test + void passingRunReportsValidCasesOnly() { + AtomicInteger finals = new AtomicInteger(); + RunReport report = Hegel.run( + tc -> { + int x = tc.draw(integers().min(0).max(100)); + if (tc.isFinal()) { + finals.incrementAndGet(); + } + assertTrue(x >= 0); + }, + QUIET.testCases(20), + Reporter.silent()); + assertTrue(report.passed()); + assertEquals(0, finals.get()); + assertTrue(report.statistics().valid() >= 20, report.statistics().toString()); + assertEquals(report.statistics().valid(), report.statistics().total()); + assertTrue(report.failures().isEmpty()); + } + + @Test + void healthCheckFailureIsReported() { + RunReport report = Hegel.run( + tc -> { + tc.draw(integers()); + tc.assume(false); + }, + QUIET, + Reporter.silent()); + assertEquals(RunStatus.ERROR, report.status()); + assertTrue(report.healthCheckFailed(), report.error().orElse("")); + assertTrue(report.statistics().invalid() > 0); + } + + /** A foreign composite generator: two draws enclosed in a custom-labelled span. */ + private static final Generator PAIR = tc -> tc.span(Label.of("dev.hegel.test.pair"), () -> new int[] { + tc.draw(integers().min(0).max(50)), tc.draw(integers().min(0).max(50)) + }); + + @Test + void foreignGeneratorsCanOpenSpans() { + RunReport report = Hegel.run( + tc -> { + int[] p = tc.draw(PAIR, "p"); + assertTrue(p[0] + p[1] < 60, "sum too big"); + }, + QUIET, + Reporter.silent()); + assertEquals(RunStatus.FAILED, report.status()); + int[] shrunk = (int[]) report.failures().get(0).draws().get("p"); + // The engine shrinks the pair as a unit toward the boundary. + assertEquals(60, shrunk[0] + shrunk[1]); + } + + @Test + void testReturnsTheReportOfAPassedRun() { + RunReport report = Hegel.test(tc -> tc.draw(integers()), QUIET.testCases(5), Reporter.silent()); + assertTrue(report.passed()); + assertEquals(RunStatus.PASSED, Hegel.run(tc -> {}).status()); + } +} diff --git a/src/test/java/dev/hegel/OutputTest.java b/shared/src/test/java/dev/hegel/OutputTest.java similarity index 69% rename from src/test/java/dev/hegel/OutputTest.java rename to shared/src/test/java/dev/hegel/OutputTest.java index 3be4651..96e938f 100644 --- a/src/test/java/dev/hegel/OutputTest.java +++ b/shared/src/test/java/dev/hegel/OutputTest.java @@ -21,7 +21,7 @@ class OutputTest { private static String run(Settings settings, java.util.function.Consumer body) { ByteArrayOutputStream buf = new ByteArrayOutputStream(); PrintStream out = new PrintStream(buf, true, StandardCharsets.UTF_8); - Runner.run(Engine.get(), settings, body, System.getenv(), out); + Runner.run(Engine.get(), settings, body, Reporter.printing(out)).throwIfFailed(); return buf.toString(StandardCharsets.UTF_8); } @@ -35,15 +35,15 @@ void failingRunPrintsShrunkDrawsAndNotes() { assertThrows( AssertionError.class, () -> Runner.run( - Engine.get(), - new Settings().seed(123).database(Database.disabled()), - tc -> { - int x = tc.draw(integers().min(0).max(1000), "x"); - tc.note("observed x=" + x); - assertTrue(x <= 10); - }, - System.getenv(), - out)); + Engine.get(), + new Settings().seed(123).database(Database.disabled()), + tc -> { + int x = tc.draw(integers().min(0).max(1000), "x"); + tc.note("observed x=" + x); + assertTrue(x <= 10); + }, + Reporter.printing(out)) + .throwIfFailed()); String s = buf.toString(StandardCharsets.UTF_8); assertTrue(s.contains("x = 11;"), s); assertTrue(s.contains("observed x=11"), s); @@ -59,14 +59,4 @@ void passingRunPrintsNothing() { }); assertEquals("", out); } - - @Test - void singleTestCaseModeReportsItsOnlyCase() { - // SINGLE_TEST_CASE mode has no replay phase, so its one (passing) case reports directly, - // exercising the `single` reporting branch end-to-end. - String out = run( - new Settings().mode(Mode.SINGLE_TEST_CASE).database(Database.disabled()), - tc -> tc.draw(integers().min(5).max(5), "only")); - assertTrue(out.contains("only = 5;"), out); - } } diff --git a/shared/src/test/java/dev/hegel/ReporterTest.java b/shared/src/test/java/dev/hegel/ReporterTest.java new file mode 100644 index 0000000..186799e --- /dev/null +++ b/shared/src/test/java/dev/hegel/ReporterTest.java @@ -0,0 +1,102 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** The built-in reporters and the {@link Label} constants. */ +class ReporterTest { + /** The captured output with platform line endings normalised, so exact comparisons hold on Windows. */ + private static String text(ByteArrayOutputStream buf) { + return buf.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + + @Test + void silentReporterIgnoresEveryCallback() { + Reporter silent = Reporter.silent(); + RunReport report = new RunReport(RunStatus.PASSED, new RunStatistics.Counter().snapshot(), null, List.of()); + Failure failure = new Failure("origin", "blob", null, new AssertionError("x"), Map.of(), List.of()); + silent.runStarted(new Settings()); + silent.engineOutput("line"); + silent.caseStarted(false); + silent.draw("x", 1, true); + silent.note("n", true); + silent.caseFinished(CaseOutcome.VALID, false); + silent.failuresFound(1); + silent.failure(failure); + silent.runFinished(report); + } + + @Test + void printingReporterFormatsDrawsNotesAndEngineOutput() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + Reporter printing = Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8)); + printing.runStarted(new Settings()); + printing.engineOutput("engine line"); + printing.caseStarted(true); // a lone failure: no separating blank line + printing.draw("xs", List.of(1, 2), true); + printing.note("a note", true); + printing.caseFinished(CaseOutcome.INTERESTING, true); + printing.failure(new Failure("o", "b64", null, new AssertionError("x"), Map.of(), List.of())); // printBlob off + assertEquals("engine line\nxs = [1, 2];\na note\n", text(buf)); + } + + @Test + void printingReporterPrintsTheCaveatThenTheReproducer() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + Reporter printing = Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8)); + printing.runStarted(new Settings().printBlob(true)); + printing.failure(new Failure( + "o", "b64", "nondeterministic failure, confirmed", new AssertionError("x"), Map.of(), List.of())); + assertEquals( + "note: nondeterministic failure, confirmed\n\nTo reproduce this failure, replay it with:\n" + + " @HegelTest(reproduceFailure = \"b64\")\n", + text(buf)); + } + + @Test + void printingReporterHonoursVerbosity() { + ByteArrayOutputStream quietBuf = new ByteArrayOutputStream(); + Reporter quiet = Reporter.printing(new PrintStream(quietBuf, true, StandardCharsets.UTF_8)); + quiet.runStarted(new Settings().verbosity(Verbosity.QUIET)); + quiet.draw("x", 1, true); + quiet.note("hidden", true); + assertEquals("", quietBuf.toString(StandardCharsets.UTF_8)); + + // Verbose: non-final draws print like final ones (the runner decides what to send). + ByteArrayOutputStream verboseBuf = new ByteArrayOutputStream(); + Reporter verbose = Reporter.printing(new PrintStream(verboseBuf, true, StandardCharsets.UTF_8)); + verbose.runStarted(new Settings().verbosity(Verbosity.VERBOSE)); + verbose.draw("x", 1, false); + verbose.note("shown", false); + assertEquals("x = 1;\nshown\n", text(verboseBuf)); + } + + @Test + void labelsMatchTheEngineConstants() { + assertEquals(Label.of("dev.hegel.list"), Label.LIST); + assertEquals(Label.of("dev.hegel.list_element"), Label.LIST_ELEMENT); + assertEquals(Label.of("dev.hegel.set"), Label.SET); + assertEquals(Label.of("dev.hegel.set_element"), Label.SET_ELEMENT); + assertEquals(Label.of("dev.hegel.map"), Label.MAP); + assertEquals(Label.of("dev.hegel.map_entry"), Label.MAP_ENTRY); + assertEquals(Label.of("dev.hegel.tuple"), Label.TUPLE); + assertEquals(Label.of("dev.hegel.one_of"), Label.ONE_OF); + assertEquals(Label.of("dev.hegel.optional"), Label.OPTIONAL); + assertEquals(Label.of("dev.hegel.fixed_dict"), Label.FIXED_DICT); + assertEquals(Label.of("dev.hegel.flat_map"), Label.FLAT_MAP); + assertEquals(Label.of("dev.hegel.filter"), Label.FILTER); + assertEquals(Label.of("dev.hegel.mapped"), Label.MAPPED); + assertEquals(Label.of("dev.hegel.sampled_from"), Label.SAMPLED_FROM); + assertEquals(Label.of("dev.hegel.enum_variant"), Label.ENUM_VARIANT); + assertEquals(Label.of("dev.hegel.stateful_rule"), Label.STATEFUL_RULE); + // Minted labels are stable and match the hash the composite generator already uses. + assertEquals(Label.COMPOSITE, Label.of("dev.hegel.composite")); + assertEquals(Label.of("my.frontend.pair"), Label.of("my.frontend.pair")); + } +} diff --git a/shared/src/test/java/dev/hegel/ReproduceFailureTest.java b/shared/src/test/java/dev/hegel/ReproduceFailureTest.java new file mode 100644 index 0000000..9b9549e --- /dev/null +++ b/shared/src/test/java/dev/hegel/ReproduceFailureTest.java @@ -0,0 +1,178 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import dev.hegel.lowlevel.Libhegel; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** Reproduce-blob round trip against the real engine: print a blob, replay it, detect staleness. */ +class ReproduceFailureTest { + private static final Consumer FAILING = tc -> { + int x = tc.draw(integers().min(0).max(1000), "x"); + assertTrue(x <= 10, "x was too big: " + x); + }; + + private static String runCapturing(Settings settings, Consumer body, Class want) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + PrintStream out = new PrintStream(buf, true, StandardCharsets.UTF_8); + assertThrows( + want, + () -> Runner.run(Engine.get(), settings, body, Reporter.printing(out)) + .throwIfFailed()); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void printedBlobReplaysTheExactFailure() { + String output = runCapturing( + new Settings().database(Database.disabled()).printBlob(true), FAILING, AssertionError.class); + assertTrue(output.contains("reproduceFailure = \""), output); + String tail = output.substring(output.indexOf("reproduceFailure = \"") + "reproduceFailure = \"".length()); + String blob = tail.substring(0, tail.indexOf('"')); + + // Replaying the blob reproduces the shrunk counterexample (x = 11) and the same failure. + String replayOutput = runCapturing( + new Settings().database(Database.disabled()).reproduceFailure(blob), FAILING, AssertionError.class); + assertTrue(replayOutput.contains("x = 11;"), replayOutput); + + // A body that no longer fails makes the blob stale. + HegelException stale = assertThrows( + HegelException.class, + () -> Hegel.test( + tc -> tc.draw(integers().min(0).max(1000), "x"), + new Settings().database(Database.disabled()).reproduceFailure(blob))); + assertTrue(stale.getMessage().contains("did not reproduce"), stale.getMessage()); + } + + @Test + void standaloneSingleAttemptReplayRemainsAvailable() { + // The runner drives blob replays through the engine's run loop, but the binding still + // exposes the one-shot hegel_test_case_from_blob for frontends built on hegel-lowlevel. + Libhegel lib = Engine.get(); + RunReport report = + Hegel.run(FAILING, new Settings().database(Database.disabled()).seed(1), Reporter.silent()); + String blob = report.failures().get(0).reproduceBlob().orElseThrow(); + long[] settings = new long[1]; + assertEquals(Abi.OK, lib.settingsNew(settings)); + lib.settingsDatabase(settings[0], ""); + List lines = new ArrayList<>(); + long[] tc = new long[1]; + assertEquals(Abi.OK, lib.testCaseFromBlob(settings[0], blob, lines::add, tc)); + assertTrue(tc[0] != 0); + assertEquals(Abi.OK, lib.markComplete(tc[0], Abi.STATUS_VALID, null)); + lib.testCaseFree(tc[0]); + lib.settingsFree(settings[0]); + } + + @Test + void corruptBlobsAreRejected() { + HegelException e = assertThrows( + HegelException.class, + () -> Hegel.test( + FAILING, new Settings().database(Database.disabled()).reproduceFailure("!!!"))); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + } + + @Test + void aTestThatFailsOnceIsReportedUnconfirmedWithACaveat() { + AtomicInteger calls = new AtomicInteger(); + Consumer onceOnly = tc -> { + tc.draw(integers(), "x"); + if (calls.incrementAndGet() == 1) { + throw new AssertionError("only the first time"); + } + }; + Settings settings = new Settings().database(Database.disabled()).seed(3); + RunReport report = Hegel.run(onceOnly, settings, Reporter.silent()); + assertEquals(RunStatus.FAILED, report.status()); + Failure f = report.failures().get(0); + assertTrue(f.nondeterministic(), f.toString()); + String caveat = f.caveat().orElseThrow(); + assertTrue(caveat.startsWith("unconfirmed failure"), caveat); + // No replay ever failed again, so there is no blob, and the only failing execution was the + // unstamped discovery: the exception is the body's own, the draws were not recorded. + assertEquals(Optional.empty(), f.reproduceBlob()); + assertTrue(f.exception() instanceof AssertionError, String.valueOf(f.exception())); + assertTrue(f.draws().isEmpty(), f.draws().toString()); + + // Hegel.test rethrows the body's own failure, and the printed report carries the caveat. + calls.set(0); + String output = runCapturing(settings, onceOnly, AssertionError.class); + assertTrue(output.contains("note: unconfirmed failure"), output); + assertTrue(!output.contains("reproduceFailure = \""), output); + } + + @Test + void errorStrictnessAbortsOnANondeterministicTest() { + AtomicInteger calls = new AtomicInteger(); + HegelException e = assertThrows( + HegelException.class, + () -> Hegel.test( + tc -> { + tc.draw(integers()); + if (calls.incrementAndGet() == 1) { + throw new AssertionError("only the first time"); + } + }, + new Settings() + .database(Database.disabled()) + .seed(3) + .nondeterminismStrictness(NondeterminismStrictness.ERROR))); + assertTrue(e.getMessage().toLowerCase().contains("flaky"), e.getMessage()); + } + + @Test + void confirmedNondeterministicFailureCarriesABlobThatReplays() { + // Fails every other time a large value is drawn: nondeterministic, but reproducible often + // enough for the engine to confirm it, shrink it, and hand back a blob. + AtomicInteger bigDraws = new AtomicInteger(); + Consumer intermittent = tc -> { + int x = tc.draw(integers().min(0).max(1000), "x"); + if (x > 10 && bigDraws.incrementAndGet() % 2 == 0) { + throw new AssertionError("x was too big (this time): " + x); + } + }; + Settings settings = new Settings().database(Database.disabled()).seed(7).printBlob(true); + String output = runCapturing(settings, intermittent, AssertionError.class); + assertTrue(output.contains("note: nondeterministic failure, confirmed"), output); + assertTrue(output.contains("reproduceFailure = \""), output); + String tail = output.substring(output.indexOf("reproduceFailure = \"") + "reproduceFailure = \"".length()); + String blob = tail.substring(0, tail.indexOf('"')); + + // The engine replays a nondeterministic blob until one of its recorded runs fails again. + RunReport replay = Hegel.run( + intermittent, new Settings().database(Database.disabled()).reproduceFailure(blob), Reporter.silent()); + assertEquals(RunStatus.FAILED, replay.status()); + assertTrue(replay.failures().get(0).nondeterministic()); + assertTrue(replay.failures().get(0).exception() instanceof AssertionError); + assertEquals(Optional.empty(), replay.failures().get(0).reproduceBlob()); + } + + @Test + void explicitBackendsRun() { + Hegel.test( + tc -> tc.draw(integers()), + new Settings() + .database(Database.disabled()) + .backend(Backend.DEFAULT) + .testCases(5)); + Hegel.test( + tc -> tc.draw(integers()), + new Settings() + .database(Database.disabled()) + .backend(Backend.URANDOM) + .testCases(5)); + } +} diff --git a/shared/src/test/java/dev/hegel/RunReportTest.java b/shared/src/test/java/dev/hegel/RunReportTest.java new file mode 100644 index 0000000..21a9d95 --- /dev/null +++ b/shared/src/test/java/dev/hegel/RunReportTest.java @@ -0,0 +1,404 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** The report and reporter side of {@link Runner}, driven against the fake binding. */ +class RunReportTest { + + /** A reporter that records every callback as one line, in order. */ + static final class RecordingReporter implements Reporter { + final List events = new ArrayList<>(); + + @Override + public void runStarted(Settings settings) { + events.add("runStarted:" + settings.testCases); + } + + @Override + public void engineOutput(String line) { + events.add("engineOutput:" + line); + } + + @Override + public void caseStarted(boolean finalReplay) { + events.add("caseStarted:" + finalReplay); + } + + @Override + public void draw(String label, Object value, boolean finalReplay) { + events.add("draw:" + label + "=" + value + ":" + finalReplay); + } + + @Override + public void note(String message, boolean finalReplay) { + events.add("note:" + message + ":" + finalReplay); + } + + @Override + public void caseFinished(CaseOutcome outcome, boolean finalReplay) { + events.add("caseFinished:" + outcome + ":" + finalReplay); + } + + @Override + public void failuresFound(int count) { + events.add("failuresFound:" + count); + } + + @Override + public void failure(Failure failure) { + events.add("failure:" + failure.origin()); + } + + @Override + public void runFinished(RunReport report) { + events.add("runFinished:" + report.status()); + } + } + + private static RunReport report(FakeLibhegel fake, Settings s, Consumer body, Reporter reporter) { + return Runner.run(fake, s, body, reporter); + } + + private static RunReport report(FakeLibhegel fake, Settings s, Consumer body) { + return report(fake, s, body, Reporter.silent()); + } + + @Test + void passingRunReportsItsStatistics() { + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 3; + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> tc.draw(integers())); + assertEquals(RunStatus.PASSED, r.status()); + assertTrue(r.passed()); + assertEquals(3, r.statistics().valid()); + assertEquals(3, r.statistics().total()); + assertEquals(0, r.statistics().invalid()); + assertEquals(0, r.statistics().overrun()); + assertEquals(0, r.statistics().interesting()); + assertEquals(Optional.empty(), r.error()); + assertFalse(r.healthCheckFailed()); + assertTrue(r.failures().isEmpty()); + assertEquals( + "RunStatistics{total=3, valid=3, invalid=0, overrun=0, interesting=0}", + r.statistics().toString()); + r.throwIfFailed(); // a passed report throws nothing + } + + @Test + void statisticsCountEveryOutcome() { + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 4; + AtomicInteger n = new AtomicInteger(); + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> { + switch (n.incrementAndGet()) { + case 1: + tc.assume(false); + break; + case 2: + throw new StopTest(); + case 3: + throw new AssertionError("interesting, but the fake's verdict is PASSED"); + default: + break; + } + }); + assertEquals(1, r.statistics().invalid()); + assertEquals(1, r.statistics().overrun()); + assertEquals(1, r.statistics().interesting()); + assertEquals(1, r.statistics().valid()); + assertEquals(4, r.statistics().total()); + } + + @Test + void failedRunCarriesTheReplayedFailure() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + fake.integerValue = 7L; + AssertionError err = new AssertionError("boom"); + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> { + long x = tc.draw(integers(), "x"); + tc.draw(integers()); + tc.note("saw " + x); + throw err; + }); + assertEquals(RunStatus.FAILED, r.status()); + assertFalse(r.passed()); + assertEquals(1, r.failures().size()); + Failure f = r.failures().get(0); + // The engine echoes back the origin the runner marked the case with. + assertEquals(Runner.originOf(err, List.of()), f.origin()); + assertEquals(Optional.of("blob-1"), f.reproduceBlob()); + assertSame(err, f.exception()); + assertFalse(f.nondeterministic()); + assertEquals(Optional.empty(), f.caveat()); + // The fake stamps the case for capture, so its draws and notes are the report. + assertEquals(List.of("x", "draw_1"), List.copyOf(f.draws().keySet())); + assertEquals(7, f.draws().get("x")); + assertEquals(List.of("saw 7"), f.notes()); + assertThrows(UnsupportedOperationException.class, () -> f.draws().put("y", 1)); + assertThrows(UnsupportedOperationException.class, () -> f.notes().add("more")); + assertThrows(UnsupportedOperationException.class, () -> r.failures().clear()); + // The engine ran the one case; nothing is replayed client-side. + assertEquals(1, r.statistics().total()); + assertEquals(1, r.statistics().interesting()); + assertSame(err, assertThrows(AssertionError.class, r::throwIfFailed)); + } + + @Test + void nondeterministicFailureCarriesItsCaveat() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("nd-blob"); + fake.failureCaveats.add("nondeterministic failure, confirmed: failed 3 of 20 replays"); + AssertionError err = new AssertionError("sometimes"); + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> { + throw err; + }); + assertEquals(RunStatus.FAILED, r.status()); + Failure f = r.failures().get(0); + assertTrue(f.nondeterministic()); + assertEquals(Optional.of("nondeterministic failure, confirmed: failed 3 of 20 replays"), f.caveat()); + assertEquals(Optional.of("nd-blob"), f.reproduceBlob()); + // The failure is the test's own, caveat or not. + assertSame(err, assertThrows(AssertionError.class, r::throwIfFailed)); + } + + @Test + void unconfirmedFailureIsReportedFromItsDiscovery() { + // A test that failed once and never again: the engine reports the origin unconfirmed, with a + // caveat and no blob, and the only failing execution was the unstamped discovery, whose + // exception is all the runner kept. + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 3; + fake.captureSequence = new boolean[] {false, true, true}; + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add(null); + fake.failureCaveats.add("nondeterministic failure, unconfirmed: failed 0 of 20 replays"); + AtomicInteger calls = new AtomicInteger(); + AssertionError err = new AssertionError("only once"); + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> { + tc.draw(integers(), "x"); + if (calls.incrementAndGet() == 1) { + throw err; + } + }); + Failure f = r.failures().get(0); + assertTrue(f.nondeterministic()); + assertEquals(Optional.empty(), f.reproduceBlob()); + assertSame(err, f.exception()); + assertTrue(f.draws().isEmpty()); + assertSame(err, assertThrows(AssertionError.class, r::throwIfFailed)); + } + + @Test + void stampedCaptureOutranksALaterUnstampedOne() { + // Shrink probes (unstamped) keep failing after the stamped replay: the report still comes + // from the stamped execution, and among stamped ones the newest wins. + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 4; + fake.captureSequence = new boolean[] {false, true, true, false}; + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + AtomicInteger calls = new AtomicInteger(); + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> { + int n = calls.incrementAndGet(); + tc.note("case " + n); + throw new AssertionError("always"); + }); + assertEquals(List.of("case 3"), r.failures().get(0).notes()); + } + + @Test + void checkedExceptionsAreRethrownAsIs() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + IOException io = new IOException("checked"); + RunReport r = report(fake, new Settings().database(Database.disabled()), tc -> { + throw sneaky(io); + }); + assertSame(io, r.failures().get(0).exception()); + assertSame(io, assertThrows(IOException.class, r::throwIfFailed)); + } + + @SuppressWarnings("unchecked") + private static RuntimeException sneaky(Throwable t) throws T { + throw (T) t; + } + + @Test + void erroredRunCarriesTheEngineMessage() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_ERROR; + fake.runError = "FailedHealthCheck: FilterTooMuch"; + RunReport health = report(fake, new Settings().database(Database.disabled()), tc -> tc.assume(false)); + assertEquals(RunStatus.ERROR, health.status()); + assertFalse(health.passed()); + assertEquals(Optional.of("FailedHealthCheck: FilterTooMuch"), health.error()); + assertTrue(health.healthCheckFailed()); + assertTrue(health.failures().isEmpty()); + assertEquals(1, health.statistics().invalid()); + assertThrows(HealthCheckFailure.class, health::throwIfFailed); + + FakeLibhegel other = new FakeLibhegel(); + other.runStatus = Abi.RUN_STATUS_ERROR; + other.runError = "engine exploded"; + RunReport engine = report(other, new Settings().database(Database.disabled()), tc -> {}); + assertFalse(engine.healthCheckFailed()); + assertEquals( + "engine exploded", + assertThrows(HegelException.class, engine::throwIfFailed).getMessage()); + } + + @Test + void reporterCallbacksArriveInOrder() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + RecordingReporter reporter = new RecordingReporter(); + RunReport r = report( + fake, + new Settings().database(Database.disabled()).testCases(5), + tc -> { + tc.draw(integers().min(3), "x"); + tc.note("hi"); + throw new AssertionError("boom"); + }, + reporter); + // Engine output goes through the run's callback to the reporter, whenever it arrives. + fake.output.accept("engine says hi"); + // The stamped case's draws and notes are not reported live; they are replayed, flagged as + // such, once the engine reports the failure. + assertEquals( + List.of( + "runStarted:5", + "caseStarted:false", + "caseFinished:INTERESTING:false", + "failuresFound:1", + "caseStarted:true", + "draw:x=3:true", + "note:hi:true", + "caseFinished:INTERESTING:true", + "failure:" + r.failures().get(0).origin(), + "runFinished:FAILED", + "engineOutput:engine says hi"), + reporter.events); + } + + @Test + void verboseRunsReportEveryCaseLiveAndReplayTheCapture() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + RecordingReporter reporter = new RecordingReporter(); + RunReport r = report( + fake, + new Settings().database(Database.disabled()).verbosity(Verbosity.VERBOSE), + tc -> { + tc.draw(integers().min(3), "x"); + tc.note("hi"); + throw new AssertionError("boom"); + }, + reporter); + // The case's draws and notes are reported live (flagged as such) ... + assertTrue(reporter.events.contains("draw:x=3:false"), reporter.events.toString()); + assertTrue(reporter.events.contains("note:hi:false"), reporter.events.toString()); + // ... and replayed with the failure report. + assertTrue(reporter.events.contains("draw:x=3:true"), reporter.events.toString()); + assertEquals(Map.of("x", 3), r.failures().get(0).draws()); + assertEquals(List.of("hi"), r.failures().get(0).notes()); + } + + @Test + void multipleFailuresAreReportedInEngineOrder() { + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 2; // one case per distinct bug + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + fake.failureBlobs.add("blob-2"); + AtomicInteger replay = new AtomicInteger(); + RecordingReporter reporter = new RecordingReporter(); + RunReport r = report( + fake, + new Settings().database(Database.disabled()).reportMultipleFailures(true), + tc -> { + if (replay.incrementAndGet() % 2 == 1) { + throw new AssertionError("bug one"); + } + throw new IllegalStateException("bug two"); + }, + reporter); + assertEquals(2, r.failures().size()); + assertEquals(Optional.of("blob-1"), r.failures().get(0).reproduceBlob()); + assertTrue(r.failures().get(0).origin().startsWith("AssertionError at ")); + assertTrue(r.failures().get(1).origin().startsWith("IllegalStateException at ")); + assertTrue(reporter.events.contains("failuresFound:2")); + AssertionError e = assertThrows(AssertionError.class, r::throwIfFailed); + assertEquals(2, e.getSuppressed().length); + } + + @Test + void reproduceFailureReportsTheReplayedFailure() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add(null); // a blob replay's failure carries no fresh blob + RecordingReporter reporter = new RecordingReporter(); + IllegalStateException err = new IllegalStateException("reproduced"); + RunReport r = report( + fake, + new Settings().reproduceFailure("stored-blob"), + tc -> { + tc.draw(integers().min(1), "x"); + throw err; + }, + reporter); + assertEquals("stored-blob", fake.startedBlob); + assertEquals(RunStatus.FAILED, r.status()); + Failure f = r.failures().get(0); + assertEquals(Optional.empty(), f.reproduceBlob()); + assertSame(err, f.exception()); + // The test suite itself lives in dev.hegel, which counts as infrastructure, so the origin + // names the exception and whatever frame sits below the suite. + assertTrue(f.origin().startsWith("IllegalStateException at "), f.origin()); + assertEquals(Map.of("x", 1), f.draws()); + assertEquals(1, r.statistics().total()); + assertEquals(1, r.statistics().interesting()); + // The blob run is pumped like any other, then its failure is reported from the capture. + assertEquals( + List.of( + "runStarted:100", + "caseStarted:false", + "caseFinished:INTERESTING:false", + "failuresFound:1", + "caseStarted:true", + "draw:x=1:true", + "caseFinished:INTERESTING:true"), + reporter.events.subList(0, 7)); + assertEquals("runFinished:FAILED", reporter.events.get(reporter.events.size() - 1)); + } + + @Test + void statisticsCounterStartsAtZero() { + RunStatistics.Counter counter = new RunStatistics.Counter(); + assertEquals(0, counter.snapshot().total()); + counter.record(CaseOutcome.VALID); + counter.record(CaseOutcome.VALID); + assertEquals(2, counter.snapshot().valid()); + } +} diff --git a/shared/src/test/java/dev/hegel/RunnerTest.java b/shared/src/test/java/dev/hegel/RunnerTest.java new file mode 100644 index 0000000..bc85704 --- /dev/null +++ b/shared/src/test/java/dev/hegel/RunnerTest.java @@ -0,0 +1,526 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** Covers {@link Runner} branches with a fake binding (no engine). */ +class RunnerTest { + + private static PrintStream capture(ByteArrayOutputStream buf) { + return new PrintStream(buf, true, StandardCharsets.UTF_8); + } + + private static void run(FakeLibhegel fake, Settings s, Consumer body) { + Runner.run(fake, s, body, Reporter.printing(capture(new ByteArrayOutputStream()))) + .throwIfFailed(); + } + + @Test + void happyPathMarksValidAndFreesEverything() { + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 3; + run(fake, new Settings().database(Database.disabled()), tc -> tc.draw(integers())); + assertEquals(List.of(Abi.STATUS_VALID, Abi.STATUS_VALID, Abi.STATUS_VALID), fake.markedStatuses); + assertEquals(3, fake.freedTestCases); + assertTrue(fake.runFreed); + assertTrue(fake.runResultFreed); + assertTrue(fake.settingsFreed); + } + + @Test + void runStartFailurePropagatesAndFreesSettings() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStartFails = true; + fake.lastError = "no start"; + HegelException e = assertThrows(HegelException.class, () -> run(fake, new Settings(), tc -> {})); + assertTrue(e.getMessage().contains("no start")); + assertTrue(fake.settingsFreed); + } + + @Test + void nextTestCaseFailurePropagates() { + FakeLibhegel fake = new FakeLibhegel(); + fake.nextTestCaseFails = true; + fake.lastError = "explode"; + HegelException e = assertThrows( + HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); + assertTrue(e.getMessage().contains("explode")); + assertTrue(fake.runFreed); + } + + @Test + void markCompleteErrorThrowsAndStillFreesTheCase() { + FakeLibhegel fake = new FakeLibhegel(); + fake.markCompleteRc = Abi.E_ALREADY_COMPLETE; + assertThrows(HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); + assertEquals(1, fake.freedTestCases); + } + + @Test + void assumeMapsToInvalid() { + FakeLibhegel fake = new FakeLibhegel(); + run(fake, new Settings().database(Database.disabled()), tc -> tc.assume(false)); + assertEquals(List.of(Abi.STATUS_INVALID), fake.markedStatuses); + } + + @Test + void stopTestMapsToOverrun() { + FakeLibhegel fake = new FakeLibhegel(); + fake.generateIntegerRc = Abi.E_STOP_TEST; + run(fake, new Settings().database(Database.disabled()), tc -> tc.draw(integers())); + assertEquals(List.of(Abi.STATUS_OVERRUN), fake.markedStatuses); + } + + @Test + void assertionFailureMapsToInterestingAndRecordsOrigin() { + FakeLibhegel fake = new FakeLibhegel(); + run(fake, new Settings().database(Database.disabled()), tc -> { + throw new AssertionError("nope"); + }); + assertEquals(List.of(Abi.STATUS_INTERESTING), fake.markedStatuses); + assertTrue(fake.markedOrigins.get(0) != null); + } + + @Test + void hegelExceptionFromBodyPropagates() { + FakeLibhegel fake = new FakeLibhegel(); + fake.generateBooleanRc = Abi.E_BACKEND; + assertThrows( + HegelException.class, + () -> run(fake, new Settings().database(Database.disabled()), tc -> tc.draw(Generators.booleans()))); + // The case was not marked complete; run_free drains it, but the handle was still freed. + assertTrue(fake.markedStatuses.isEmpty()); + assertEquals(1, fake.freedTestCases); + } + + @Test + void failedRunReplaysTheBlobAndRethrowsTheOriginalException() { + // The default (report_multiple_failures off) surfaces the body's own exception instance — + // no "Hegel found ..." wrapper — so the stack trace and type are the user's. Covers both an + // Error (e.g. an assertion failure) and a RuntimeException. + AssertionError err = new AssertionError("boom-error"); + assertSame( + err, + assertThrows( + AssertionError.class, + () -> runFailing(tc -> { + throw err; + }))); + IllegalStateException rt = new IllegalStateException("boom-rt"); + assertSame( + rt, + assertThrows( + IllegalStateException.class, + () -> runFailing(tc -> { + throw rt; + }))); + } + + /** Drive a run whose result is FAILED with one blob, replaying {@code body}. */ + private static FakeLibhegel runFailing(Consumer body) { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + run(fake, new Settings().database(Database.disabled()), body); + return fake; + } + + @Test + void failuresAreReportedFromTheCaptureWithoutAClientReplay() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-xyz"); + assertThrows( + AssertionError.class, + () -> run(fake, new Settings().database(Database.disabled()), tc -> { + throw new AssertionError("always"); + })); + // The engine owns every replay: nothing is replayed from the blob client-side. + assertTrue(fake.replayedBlobs.isEmpty()); + assertNull(fake.startedBlob); + assertEquals(1, fake.freedTestCases); + } + + @Test + void failureWithoutACapturedExecutionIsAnInternalError() { + // The engine reports a failure whose origin never failed in this process: a plumbing bug. + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + fake.failureOrigins.add("AssertionError at Elsewhere.java:1"); + HegelException e = assertThrows( + HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); + assertTrue(e.getMessage().contains("no captured failing execution"), e.getMessage()); + assertTrue(e.getMessage().contains("Elsewhere.java:1"), e.getMessage()); + } + + @Test + void unstampedCasesKeepOnlyTheirException() { + FakeLibhegel fake = new FakeLibhegel(); + fake.captureSequence = new boolean[] {false}; + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + AssertionError err = new AssertionError("always"); + RunReport report = Runner.run( + fake, + new Settings().database(Database.disabled()).printBlob(false), + tc -> { + tc.draw(integers(), "x"); + tc.note("unseen"); + throw err; + }, + Reporter.printing(capture(buf))); + assertSame(err, report.failures().get(0).exception()); + assertTrue(report.failures().get(0).draws().isEmpty()); + assertTrue(report.failures().get(0).notes().isEmpty()); + assertEquals("", buf.toString(StandardCharsets.UTF_8)); + } + + @Test + void multipleFailuresAggregateWithSuppressedOriginals() { + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 2; // one case per distinct bug + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + fake.failureBlobs.add("blob-2"); + AtomicInteger replay = new AtomicInteger(); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + AssertionError e = assertThrows( + AssertionError.class, + () -> Runner.run( + fake, + new Settings().database(Database.disabled()).reportMultipleFailures(true), + tc -> { + if (replay.incrementAndGet() % 2 == 1) { + throw new AssertionError("bug one"); + } + throw new IllegalStateException("bug two"); + }, + Reporter.printing(capture(buf))) + .throwIfFailed()); + assertTrue(e.getMessage().contains("2 distinct failing examples"), e.getMessage()); + assertTrue(e.getMessage().contains("bug one"), e.getMessage()); + assertTrue(e.getMessage().contains("bug two"), e.getMessage()); + assertEquals(2, e.getSuppressed().length); + assertTrue(buf.toString(StandardCharsets.UTF_8).contains("2 distinct failures"), buf.toString()); + } + + @Test + void aggregateMessageHandlesNullExceptionMessages() { + FakeLibhegel fake = new FakeLibhegel(); + fake.caseCount = 2; + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-1"); + fake.failureBlobs.add("blob-2"); + AtomicInteger calls = new AtomicInteger(); + AssertionError e = assertThrows( + AssertionError.class, + () -> run(fake, new Settings(), tc -> { + if (calls.incrementAndGet() == 1) { + throw new IllegalStateException(); // null message + } + throw new UnsupportedOperationException(); // null message + })); + assertTrue(e.getMessage().contains(IllegalStateException.class.getName()), e.getMessage()); + assertTrue(e.getMessage().contains(UnsupportedOperationException.class.getName()), e.getMessage()); + } + + @Test + void printBlobPrintsTheReproducerLine() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("blob-b64"); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + assertThrows( + AssertionError.class, + () -> Runner.run( + fake, + new Settings().database(Database.disabled()).printBlob(true), + tc -> { + throw new AssertionError("always"); + }, + Reporter.printing(capture(buf))) + .throwIfFailed()); + String out = buf.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("reproduceFailure = \"blob-b64\""), out); + } + + @Test + void healthCheckErrorSurfacesAsHealthCheckFailure() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_ERROR; + fake.runError = "FailedHealthCheck: FilterTooMuch — too many rejected"; + HealthCheckFailure e = assertThrows( + HealthCheckFailure.class, + () -> run(fake, new Settings().database(Database.disabled()), tc -> tc.assume(false))); + assertTrue(e.getMessage().contains("FilterTooMuch"), e.getMessage()); + } + + @Test + void otherRunErrorsSurfaceAsHegelException() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_ERROR; + fake.runError = "engine exploded"; + HegelException e = assertThrows( + HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); + assertEquals("engine exploded", e.getMessage()); + } + + @Test + void nullRunErrorBecomesEmptyMessage() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_ERROR; + fake.runError = null; + HegelException e = assertThrows( + HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); + assertEquals("", e.getMessage()); + } + + @Test + void reproduceFailureStartsABlobRun() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add(null); + IllegalStateException err = new IllegalStateException("reproduced"); + assertSame( + err, + assertThrows( + IllegalStateException.class, + () -> run(fake, new Settings().reproduceFailure("stored-blob"), tc -> { + throw err; + }))); + assertEquals("stored-blob", fake.startedBlob); + assertTrue(fake.replayedBlobs.isEmpty()); + assertTrue(fake.runFreed); + assertTrue(fake.settingsFreed); + } + + @Test + void reproduceFailureReportsAStaleBlob() { + // The engine's blob run passed: none of its replays failed. + FakeLibhegel fake = new FakeLibhegel(); + HegelException e = + assertThrows(HegelException.class, () -> run(fake, new Settings().reproduceFailure("stale"), tc -> {})); + assertEquals(Runner.STALE_BLOB, e.getMessage()); + assertTrue(e.getMessage().contains("did not reproduce"), e.getMessage()); + } + + @Test + void reproduceFailureRejectsAnInvalidBlob() { + // An undecodable blob surfaces as the blob run's error. + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_ERROR; + fake.runError = "corrupt"; + HegelException e = + assertThrows(HegelException.class, () -> run(fake, new Settings().reproduceFailure("???"), tc -> {})); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + assertTrue(e.getMessage().contains("corrupt"), e.getMessage()); + } + + @Test + void nondeterminismStrictnessIsSentOnlyWhenSet() { + FakeLibhegel fake = new FakeLibhegel(); + run(fake, new Settings().database(Database.disabled()), tc -> {}); + assertNull(fake.strictness); + run( + fake, + new Settings().database(Database.disabled()).nondeterminismStrictness(NondeterminismStrictness.WARN), + tc -> {}); + assertEquals(Integer.valueOf(Abi.NONDETERMINISM_WARN), fake.strictness); + } + + @Test + void effectiveStrictnessIsReadBackFromTheEngine() { + FakeLibhegel fake = new FakeLibhegel(); + fake.resolvedStrictness = Abi.NONDETERMINISM_ERROR; + Settings[] seen = new Settings[1]; + Reporter reporter = new Reporter() { + @Override + public void runStarted(Settings settings) { + seen[0] = settings; + } + }; + Runner.run(fake, new Settings().database(Database.disabled()), tc -> {}, reporter); + assertEquals(NondeterminismStrictness.ERROR, seen[0].nondeterminismStrictness); + assertEquals(NondeterminismStrictness.DEFAULT, new Settings().nondeterminismStrictness); + + FakeLibhegel unknown = new FakeLibhegel(); + unknown.resolvedStrictness = 99; + HegelException e = assertThrows(HegelException.class, () -> run(unknown, new Settings(), tc -> {})); + assertTrue(e.getMessage().contains("99"), e.getMessage()); + } + + @Test + void autoBackendLeavesTheChoiceToTheEngineProfile() { + FakeLibhegel fake = new FakeLibhegel(); + run(fake, new Settings().backend(Backend.AUTO), tc -> {}); + assertNull(fake.backendCode); + FakeLibhegel explicit = new FakeLibhegel(); + run(explicit, new Settings().backend(Backend.DEFAULT), tc -> {}); + assertEquals(Abi.BACKEND_DEFAULT, explicit.backendCode); + } + + @Test + void settingsBranchesAllApplied() { + FakeLibhegel fake = new FakeLibhegel(); + Settings s = new Settings() + .testCases(10) + .seed(7) + .derandomize(true) + .reportMultipleFailures(false) + .backend(Backend.URANDOM) + .suppressHealthCheck(HealthCheck.FILTER_TOO_MUCH, HealthCheck.TOO_SLOW) + .phases(Phase.GENERATE, Phase.SHRINK) + .verbosity(Verbosity.VERBOSE) + .database(Database.path("/tmp/hegel-db")) + .name("myTest"); + run(fake, s, tc -> {}); + assertEquals(List.of(Abi.STATUS_VALID), fake.markedStatuses); + assertEquals(Phase.GENERATE.bit | Phase.SHRINK.bit, fake.phasesMask); + assertEquals(HealthCheck.FILTER_TOO_MUCH.bit | HealthCheck.TOO_SLOW.bit, fake.suppressMask); + assertEquals(Abi.BACKEND_URANDOM, fake.backendCode); + assertEquals("/tmp/hegel-db", fake.databasePath); + assertEquals("myTest", fake.databaseKey); + } + + @Test + void databaseDisabledAndCiDefaults() { + // A disabled database still sends the key: the engine derives the derandomized seed from it. + FakeLibhegel disabled = new FakeLibhegel(); + run(disabled, new Settings().database(Database.disabled()).name("d"), tc -> {}); + assertEquals("", disabled.databasePath); + assertEquals("d", disabled.databaseKey); + + // Unset settings are not sent at all: the engine's profile (which disables the database + // and derandomizes in CI) and the HEGEL_* variables stand. A name still derives a key. + FakeLibhegel named = new FakeLibhegel(); + Runner.run(named, new Settings().name("t"), tc -> {}, Reporter.printing(capture(new ByteArrayOutputStream()))); + assertEquals("unset", named.databasePath); + assertNull(named.derandomize); + assertNull(named.testCases); + assertEquals("t", named.databaseKey); + } + + @Test + void settingsConstructionFailuresAreTranslated() { + // The engine applies the HEGEL_* variables and hegel.toml while constructing the handle: a + // malformed one is the caller's mistake, anything else is an engine error. + FakeLibhegel malformed = new FakeLibhegel(); + malformed.settingsNewRc = Abi.E_INVALID_ARG; + malformed.lastError = "HEGEL_TEST_CASES must be a positive integer, got \"lots\""; + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> run(malformed, new Settings(), tc -> {})); + assertEquals(malformed.lastError, e.getMessage()); + assertTrue(!malformed.settingsFreed); + + FakeLibhegel broken = new FakeLibhegel(); + broken.settingsNewRc = Abi.E_INTERNAL; + HegelException h = assertThrows(HegelException.class, () -> run(broken, new Settings(), tc -> {})); + assertTrue(h.getMessage().contains("hegel_settings_new"), h.getMessage()); + } + + @Test + void reportersSeeTheEngineResolvedSettings() { + Settings[] seen = new Settings[1]; + Reporter recording = new Reporter() { + @Override + public void runStarted(Settings settings) { + seen[0] = settings; + } + }; + // Unset: the values the engine resolved (profile + HEGEL_* variables) are reported. + FakeLibhegel fromEngine = new FakeLibhegel(); + fromEngine.resolvedTestCases = 250; + fromEngine.resolvedPrintBlob = true; + Runner.run(fromEngine, new Settings(), tc -> {}, recording); + assertEquals(Long.valueOf(250), seen[0].testCases); + assertEquals(Boolean.TRUE, seen[0].printBlob); + + // Explicit: the caller's values win, and the test-case budget reaches the engine. + FakeLibhegel explicit = new FakeLibhegel(); + explicit.resolvedPrintBlob = true; + Runner.run(explicit, new Settings().testCases(3).printBlob(false), tc -> {}, recording); + assertEquals(Long.valueOf(3), explicit.testCases); + assertEquals(Boolean.FALSE, explicit.printBlob); + assertEquals(Long.valueOf(3), seen[0].testCases); + assertEquals(Boolean.FALSE, seen[0].printBlob); + } + + @Test + void originFallsBackToClassNameWithoutUserFrame() { + Throwable t = new RuntimeException("x"); + t.setStackTrace(new StackTraceElement[] {}); + assertEquals(RuntimeException.class.getName(), Runner.originOf(t, List.of())); + } + + @Test + void infrastructurePackagesAreSkippedWhenLocatingTheOrigin() { + RuntimeException t = new RuntimeException("x"); + t.setStackTrace(new StackTraceElement[] { + new StackTraceElement("my.infra.Glue", "call", "Glue.java", 5), + new StackTraceElement("com.example.Body", "prop", "Body.java", 42), + }); + assertEquals("RuntimeException at Glue.java:5", Runner.originOf(t, List.of())); + assertEquals("RuntimeException at Body.java:42", Runner.originOf(t, List.of("my.infra."))); + + // The setting reaches the origin handed to the engine. + FakeLibhegel fake = new FakeLibhegel(); + run(fake, new Settings().database(Database.disabled()).infrastructurePackages("my.infra."), tc -> { + throw t; + }); + assertEquals(List.of("RuntimeException at Body.java:42"), fake.markedOrigins); + } + + @Test + void caveatIsPrintedAndABloblessFailurePrintsNoReproducer() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add(null); + fake.failureCaveats.add("nondeterministic failure, unconfirmed: failed 0 of 20 replays"); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + RunReport report = Runner.run( + fake, + new Settings().database(Database.disabled()).printBlob(true), + tc -> { + tc.draw(integers().min(0), "x"); + throw new AssertionError("sometimes"); + }, + Reporter.printing(capture(buf))); + assertTrue(report.failures().get(0).nondeterministic()); + String out = buf.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertEquals("x = 0;\nnote: nondeterministic failure, unconfirmed: failed 0 of 20 replays\n", out); + } + + @Test + void quietRunsPrintNoCaveat() { + FakeLibhegel fake = new FakeLibhegel(); + fake.runStatus = Abi.RUN_STATUS_FAILED; + fake.failureBlobs.add("nd-blob"); + fake.failureCaveats.add("nondeterministic failure, confirmed: failed 9 of 20 replays"); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + Runner.run( + fake, + new Settings().database(Database.disabled()).printBlob(true).verbosity(Verbosity.QUIET), + tc -> { + throw new AssertionError("sometimes"); + }, + Reporter.printing(capture(buf))); + String out = buf.toString(StandardCharsets.UTF_8); + assertTrue(!out.contains("note:"), out); + assertTrue(out.contains("reproduceFailure = \"nd-blob\""), out); + } +} diff --git a/src/test/java/dev/hegel/SettingsHealthDatabaseTest.java b/shared/src/test/java/dev/hegel/SettingsHealthDatabaseTest.java similarity index 96% rename from src/test/java/dev/hegel/SettingsHealthDatabaseTest.java rename to shared/src/test/java/dev/hegel/SettingsHealthDatabaseTest.java index 219a004..e6aedef 100644 --- a/src/test/java/dev/hegel/SettingsHealthDatabaseTest.java +++ b/shared/src/test/java/dev/hegel/SettingsHealthDatabaseTest.java @@ -26,8 +26,8 @@ void seedMakesRunsReproducible() { assertEquals(first, second); } - // Exercises HEGEL_MODE_SINGLE_TEST_CASE. - @HegelTest(mode = Mode.SINGLE_TEST_CASE, database = Database.DISABLED) + // A one-case budget: the engine skips the simplest-example probe and generates one random case. + @HegelTest(testCases = 1, database = Database.DISABLED) void singleTestCaseRunsOnce(TestCase tc) { tc.draw(integers()); } diff --git a/src/test/java/dev/hegel/ShrinkQualityTest.java b/shared/src/test/java/dev/hegel/ShrinkQualityTest.java similarity index 100% rename from src/test/java/dev/hegel/ShrinkQualityTest.java rename to shared/src/test/java/dev/hegel/ShrinkQualityTest.java diff --git a/src/test/java/dev/hegel/SmokeTest.java b/shared/src/test/java/dev/hegel/SmokeTest.java similarity index 93% rename from src/test/java/dev/hegel/SmokeTest.java rename to shared/src/test/java/dev/hegel/SmokeTest.java index 3549659..50f2512 100644 --- a/src/test/java/dev/hegel/SmokeTest.java +++ b/shared/src/test/java/dev/hegel/SmokeTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.hegel.lowlevel.Libhegel; import org.junit.jupiter.api.Test; class SmokeTest { diff --git a/shared/src/test/java/dev/hegel/StatefulTest.java b/shared/src/test/java/dev/hegel/StatefulTest.java new file mode 100644 index 0000000..994b76a --- /dev/null +++ b/shared/src/test/java/dev/hegel/StatefulTest.java @@ -0,0 +1,235 @@ +package dev.hegel; + +import static dev.hegel.Generators.integers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Stateful (model-based) testing against the real engine. */ +class StatefulTest { + /** A correct stack model: rules mutate both the stack and a model list; invariants compare. */ + static final class StackMachine { + private final Deque stack = new ArrayDeque<>(); + private final List model = new ArrayList<>(); + + @Rule + void push(TestCase tc) { + int v = tc.draw(integers()); + stack.push(v); + model.add(0, v); + } + + @Rule + void pop(TestCase tc) { + tc.assume(!stack.isEmpty()); + assertEquals(model.remove(0), stack.pop()); + } + + @Invariant + void sizesAgree(TestCase tc) { + assertEquals(model.size(), stack.size()); + } + } + + @HegelTest(database = Database.DISABLED) + void stackMachineHoldsUnderRandomRules(TestCase tc) { + Stateful.run(new StackMachine(), tc); + } + + /** A counter whose invariant breaks once it has been incremented past 2. */ + static final class BuggyCounter { + private int n = 0; + + @Rule + void increment(TestCase tc) { + n++; + } + + @Invariant + void small(TestCase tc) { + assertTrue(n <= 2, "counter reached " + n); + } + } + + @Test + void failingInvariantIsFoundAndStepsAreReported() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + PrintStream out = new PrintStream(buf, true, StandardCharsets.UTF_8); + assertThrows( + AssertionError.class, + () -> Runner.run( + Engine.get(), + new Settings().database(Database.disabled()).seed(11), + tc -> Stateful.run(new BuggyCounter(), tc), + Reporter.printing(out)) + .throwIfFailed()); + String output = buf.toString(StandardCharsets.UTF_8); + assertTrue(output.contains("Step 1: increment"), output); + } + + /** A counter whose always-run invariant records every intermediate state it observes. */ + static final class ObservedCounter { + private int n = 0; + final List seen = new ArrayList<>(); + + @Rule + void increment(TestCase tc) { + n++; + } + + @Invariant(alwaysRun = true) + void observe(TestCase tc) { + seen.add(n); + } + } + + @HegelTest(database = Database.DISABLED) + void alwaysRunInvariantsSeeEveryStep(TestCase tc) { + // Sampled invariants may skip steps; an always-run one is checked on the initial state, + // after every rule, and again on the final state. + ObservedCounter machine = new ObservedCounter(); + Stateful.run(machine, tc); + List expected = new ArrayList<>(); + for (int i = 0; i <= machine.n; i++) { + expected.add(i); + } + expected.add(machine.n); + assertEquals(expected, machine.seen); + } + + /** Two rules whose only job is to count, per test case, how often the engine picks each. */ + static final class WeightedMachine { + final int[] counts; + + WeightedMachine(int[] counts) { + this.counts = counts; + } + + @Rule(weight = 20) + void heavy(TestCase tc) { + counts[0]++; + } + + @Rule + void light(TestCase tc) { + counts[1]++; + } + } + + @Test + void weightedRulesAreOfferedMoreOften() { + // Swarm testing disables one of the two rules in many cases, so no aggregate ratio is + // meaningful; in a case where both ran, the 20:1 hint itself is visible. + List cases = new ArrayList<>(); + Runner.run( + Engine.get(), + new Settings() + .database(Database.disabled()) + .derandomize(true) + .testCases(100), + tc -> { + int[] counts = new int[2]; + cases.add(counts); + Stateful.run(new WeightedMachine(counts), tc); + }, + Reporter.silent()) + .throwIfFailed(); + StringBuilder seen = new StringBuilder(); + boolean dominated = false; + for (int[] c : cases) { + seen.append(c[0]).append(':').append(c[1]).append(' '); + dominated |= c[1] > 0 && c[0] >= 10 * c[1]; + } + assertTrue(dominated, "expected a case where both rules ran and the weight-20 rule dominated: " + seen); + } + + @Test + void engineRejectsInvalidWeightsItIsHanded() { + // Stateful validates weights itself; going through the data source directly shows the + // engine receives the array (and enforces the same rule) rather than a NULL. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> Runner.run( + Engine.get(), + new Settings().database(Database.disabled()).testCases(1), + tc -> tc.newStateMachine( + List.of("r"), + new long[] {0}, + new double[] {0}, + List.of(), + new boolean[0], + 1, + 1, + 50), + Reporter.silent()) + .throwIfFailed()); + assertTrue(e.getMessage().toLowerCase().contains("weight"), e.getMessage()); + } + + static final class ZeroWeight { + @Rule(weight = 0) + void never(TestCase tc) {} + } + + static final class NanWeight { + @Rule(weight = Double.NaN) + void undefined(TestCase tc) {} + } + + @HegelTest(database = Database.DISABLED, testCases = 1) + void nonPositiveOrNonFiniteWeightsAreRejected(TestCase tc) { + IllegalArgumentException zero = + assertThrows(IllegalArgumentException.class, () -> Stateful.run(new ZeroWeight(), tc)); + assertTrue(zero.getMessage().contains("never"), zero.getMessage()); + IllegalArgumentException nan = + assertThrows(IllegalArgumentException.class, () -> Stateful.run(new NanWeight(), tc)); + assertTrue(nan.getMessage().contains("undefined"), nan.getMessage()); + } + + /** Rules act on previously generated values through a {@link Pool}. */ + static final class PoolMachine { + private final List live = new ArrayList<>(); + private Pool pool; + + @Rule + void create(TestCase tc) { + if (pool == null) { + pool = new Pool<>(tc); + } + int v = tc.draw(integers().min(0).max(100)); + pool.add(v); + live.add(v); + } + + @Rule + void reuse(TestCase tc) { + tc.assume(pool != null && !pool.isEmpty()); + int v = tc.draw(pool.reusable()); + assertTrue(live.contains(v), "reused a value never created: " + v); + } + + @Rule + void consume(TestCase tc) { + tc.assume(pool != null && !pool.isEmpty()); + int before = pool.size(); + int v = tc.draw(pool.consuming()); + assertTrue(live.remove((Integer) v), "consumed a value never created: " + v); + assertEquals(before - 1, pool.size()); + } + } + + @HegelTest(database = Database.DISABLED) + void poolsHandOutOnlyLiveValues(TestCase tc) { + // The pool is created against this case's handle inside the first rule, so ids stay valid. + Stateful.run(new PoolMachine(), tc); + } +} diff --git a/shared/src/test/java/dev/hegel/TestCaseTest.java b/shared/src/test/java/dev/hegel/TestCaseTest.java new file mode 100644 index 0000000..dc7988a --- /dev/null +++ b/shared/src/test/java/dev/hegel/TestCaseTest.java @@ -0,0 +1,237 @@ +package dev.hegel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.hegel.lowlevel.Abi; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TestCaseTest { + /** The captured output with platform line endings normalised, so exact comparisons hold on Windows. */ + private static String text(ByteArrayOutputStream buf) { + return buf.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + + /** + * A TestCase over a fake binding, both stamped for capture and reporting live (verbose) when + * {@code reporting}; only the reporting/target plumbing is under test here. + */ + private TestCase newCase(FakeLibhegel fake, boolean reporting, ByteArrayOutputStream buf) { + return new TestCase( + new LiveDataSource(fake, FakeLibhegel.TC), + reporting, + reporting, + Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8))); + } + + @Test + void drawReportsTopLevelWithLabelAndDefaultName() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = newCase(new FakeLibhegel(), true, buf); + tc.draw(constant(1), "x"); + tc.draw(constant(2)); + String out = buf.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("x = 1;"), out); + assertTrue(out.contains("draw_1 = 2;"), out); + } + + @Test + void drawDoesNotReportWhenNotReporting() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = newCase(new FakeLibhegel(), false, buf); + assertEquals(5, tc.draw(constant(5))); + assertEquals("", buf.toString(StandardCharsets.UTF_8)); + } + + @Test + void nestedDrawsAreNotReported() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = newCase(new FakeLibhegel(), true, buf); + Generator nested = new Generator<>() { + @Override + public Integer doDraw(TestCase inner) { + int a = inner.draw(constant(10)); // nested: should not be printed + return a + 1; + } + }; + tc.draw(nested, "top"); + String out = buf.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("top = 11;"), out); + assertEquals(1, out.lines().count()); + } + + @Test + void noteRespectsReporting() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase reporting = newCase(new FakeLibhegel(), true, buf); + reporting.note("hello"); + assertTrue(buf.toString(StandardCharsets.UTF_8).contains("hello")); + + ByteArrayOutputStream quiet = new ByteArrayOutputStream(); + newCase(new FakeLibhegel(), false, quiet).note("nope"); + assertEquals("", quiet.toString(StandardCharsets.UTF_8)); + } + + @Test + void capturedCaseRecordsDrawsAndNotesAndReplaysThem() { + TestCase tc = newCase(new FakeLibhegel(), true, new ByteArrayOutputStream()); + assertTrue(tc.isFinal()); + tc.draw(constant(1), "x"); + tc.note("first"); + tc.draw(constant(2)); + java.util.LinkedHashMap want = new java.util.LinkedHashMap<>(); + want.put("x", 1); + want.put("draw_1", 2); + assertEquals(want, tc.draws()); + assertEquals(List.of("x", "draw_1"), List.copyOf(tc.draws().keySet())); + assertEquals(List.of("first"), tc.notes()); + // The recording replays in report order, flagged as a replay. + ByteArrayOutputStream replay = new ByteArrayOutputStream(); + Reporter reporter = Reporter.printing(new PrintStream(replay, true, StandardCharsets.UTF_8)); + reporter.runStarted(new Settings()); + tc.replayTo(reporter); + assertEquals("x = 1;\nfirst\ndraw_1 = 2;\n", text(replay)); + + // A case stamped for capture but not verbose records without reporting live. + ByteArrayOutputStream silent = new ByteArrayOutputStream(); + TestCase captured = new TestCase( + new LiveDataSource(new FakeLibhegel(), FakeLibhegel.TC), + true, + Reporter.printing(new PrintStream(silent, true, StandardCharsets.UTF_8))); + captured.draw(constant(3), "y"); + captured.note("kept"); + assertEquals("", text(silent)); + assertEquals(Map.of("y", 3), captured.draws()); + assertEquals(List.of("kept"), captured.notes()); + + TestCase exploring = newCase(new FakeLibhegel(), false, new ByteArrayOutputStream()); + assertTrue(!exploring.isFinal()); + exploring.draw(constant(1), "x"); + exploring.note("ignored"); + assertTrue(exploring.draws().isEmpty()); + assertTrue(exploring.notes().isEmpty()); + } + + @Test + void repeatedNamesAreNumberedFromTheirSecondUse() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = newCase(new FakeLibhegel(), true, buf); + for (int i = 1; i <= 3; i++) { + tc.draw(constant(i), "x"); + } + tc.draw(constant(8)); + tc.draw(constant(9)); + assertEquals( + List.of("x", "x_2", "x_3", "draw_1", "draw_2"), + List.copyOf(tc.draws().keySet())); + assertEquals(3, tc.draws().get("x_3")); + assertEquals("x = 1;\nx_2 = 2;\nx_3 = 3;\ndraw_1 = 8;\ndraw_2 = 9;\n", text(buf)); + } + + @Test + void notesMadeMidDrawFollowTheDrawLine() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = newCase(new FakeLibhegel(), true, buf); + Generator noisy = new Generator<>() { + @Override + public Integer doDraw(TestCase inner) { + inner.note("inside"); + return 7; + } + }; + tc.note("before"); + tc.draw(noisy, "v"); + tc.note("after"); + assertEquals("before\nv = 7;\ninside\nafter\n", text(buf)); + assertEquals(List.of("before", "inside", "after"), tc.notes()); + } + + @Test + void notesMadeBeforeAFailingDrawAreNotLost() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = newCase(new FakeLibhegel(), true, buf); + Generator failing = new Generator<>() { + @Override + public Integer doDraw(TestCase inner) { + inner.note("about to fail"); + throw new IllegalStateException("inside"); + } + }; + assertThrows(IllegalStateException.class, () -> tc.draw(failing, "v")); + assertEquals("about to fail\n", text(buf)); + } + + @Test + void verboseCasesReportWithoutRecording() { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + TestCase tc = new TestCase( + new LiveDataSource(new FakeLibhegel(), FakeLibhegel.TC), + false, + true, + Reporter.printing(new PrintStream(buf, true, StandardCharsets.UTF_8))); + assertTrue(!tc.isFinal()); + tc.draw(constant(1), "x"); + tc.note("n"); + assertEquals("x = 1;\nn\n", text(buf)); + assertTrue(tc.draws().isEmpty()); + assertTrue(tc.notes().isEmpty()); + } + + @Test + void spanOpensAndClosesAroundTheBody() { + FakeLibhegel fake = new FakeLibhegel(); + TestCase tc = newCase(fake, false, new ByteArrayOutputStream()); + assertEquals(7, tc.span(Label.of("test.pair"), () -> 7)); + assertEquals(List.of(Label.of("test.pair")), fake.startedSpans); + assertEquals(1, fake.stoppedSpans); + // The span is closed on the exceptional path too. + assertThrows( + IllegalStateException.class, + () -> tc.span(Label.TUPLE, () -> { + throw new IllegalStateException("inside"); + })); + assertEquals(2, fake.stoppedSpans); + } + + @Test + void assumeAndTarget() { + FakeLibhegel fake = new FakeLibhegel(); + TestCase tc = newCase(fake, false, new ByteArrayOutputStream()); + tc.assume(true); + assertThrows(AssumeRejected.class, () -> tc.assume(false)); + tc.target(3.5); + tc.target(9.0, "score"); + fake.targetRc = Abi.E_STOP_TEST; + assertThrows(StopTest.class, () -> tc.target(1.0)); + } + + @Test + void reprCoversAllShapes() { + assertEquals("null", TestCase.repr(null)); + assertEquals("\"a\\\\b\\\"c\"", TestCase.repr("a\\b\"c")); + assertEquals("[1, 2]", TestCase.repr(new byte[] {1, 2})); + assertEquals("[1, \"x\"]", TestCase.repr(List.of(1, "x"))); + assertEquals("[]", TestCase.repr(List.of())); + assertEquals("{1: 2}", TestCase.repr(Map.of(1, 2))); + java.util.LinkedHashMap m = new java.util.LinkedHashMap<>(); + m.put("a", 1); + m.put("b", 2); + assertEquals("{\"a\": 1, \"b\": 2}", TestCase.repr(m)); + assertEquals("42", TestCase.repr(42)); + } + + private static Generator constant(int v) { + return new Generator<>() { + @Override + public Integer doDraw(TestCase tc) { + return v; + } + }; + } +} diff --git a/src/test/java/dev/hegel/TupleTest.java b/shared/src/test/java/dev/hegel/TupleTest.java similarity index 100% rename from src/test/java/dev/hegel/TupleTest.java rename to shared/src/test/java/dev/hegel/TupleTest.java diff --git a/src/test/java/dev/hegel/Utils.java b/shared/src/test/java/dev/hegel/Utils.java similarity index 65% rename from src/test/java/dev/hegel/Utils.java rename to shared/src/test/java/dev/hegel/Utils.java index b5b018f..2f82152 100644 --- a/src/test/java/dev/hegel/Utils.java +++ b/shared/src/test/java/dev/hegel/Utils.java @@ -37,10 +37,32 @@ static T minimal(Generator gen, Predicate condition) { return minimal(gen, condition, new Settings().testCases(500)); } + /** Disable the database and pin a deterministic, per-test seed, keeping any explicit name. */ + private static Settings resolve(Settings settings) { + Settings s = settings.database(Database.disabled()).derandomize(true); + return s.name != null ? s : s.name(callerTestName()); + } + + /** + * The simple name of the test method that called into this helper. Shrink-quality assertions + * need a deterministic run, and a derandomized run derives its seed from the database key, so + * naming each run after its own test gives it a stable seed of its own — the same property + * {@code @HegelTest} gives a normal test. Without a name every run here would derandomize off + * the engine's shared fallback key and explore the identical inputs. + */ + private static String callerTestName() { + return StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames.filter(f -> f.getDeclaringClass() != Utils.class) + .map(StackWalker.StackFrame::getMethodName) + .findFirst() + .orElse("unnamed")); + } + /** * Find the minimal generated value satisfying {@code condition} under the given {@code - * settings} (exercises shrinking). The database is always disabled, so callers only need to set - * what they care about (typically a larger {@code testCases} budget for harder shrink targets). + * settings} (exercises shrinking). The database is always disabled and the run is derandomized + * under the calling test's name, so callers only need to set what they care about (typically a + * larger {@code testCases} budget for harder shrink targets). */ static T minimal(Generator gen, Predicate condition, Settings settings) { AtomicReference found = new AtomicReference<>(); @@ -55,7 +77,7 @@ static T minimal(Generator gen, Predicate condition, Settings settings throw new AssertionError("found"); } }, - settings.database(Database.disabled())); + resolve(settings)); } catch (AssertionError expected) { // The run threw because we found and shrank a counterexample. } diff --git a/shared/src/test/java/dev/hegel/UuidGeneratorTest.java b/shared/src/test/java/dev/hegel/UuidGeneratorTest.java new file mode 100644 index 0000000..9081bdc --- /dev/null +++ b/shared/src/test/java/dev/hegel/UuidGeneratorTest.java @@ -0,0 +1,37 @@ +package dev.hegel; + +import static dev.hegel.Generators.uuids; +import static dev.hegel.Utils.assertAllExamples; +import static dev.hegel.Utils.findAny; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class UuidGeneratorTest { + @Test + void defaultGeneratesValidUuids() { + assertAllExamples(uuids(), u -> UUID.fromString(u.toString()).equals(u)); + } + + /** By default the version is unconstrained, so the engine emits non-RFC versions too. */ + @Test + void defaultIsNotPinnedToAnRfcVersion() { + findAny(uuids(), u -> u.version() < 1 || u.version() > 5); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5}) + void configuredUuidVersionsAreRespected(int version) { + assertAllExamples( + uuids().version(version), u -> UUID.fromString(u.toString()).equals(u) && u.version() == version); + } + + @ParameterizedTest + @ValueSource(ints = {0, 6, 7, 8, 9}) + void invalidVersionsAreRejected(int version) { + assertThrows(IllegalArgumentException.class, () -> uuids().version(version)); + } +} diff --git a/src/main/java/dev/hegel/Abi.java b/src/main/java/dev/hegel/Abi.java deleted file mode 100644 index a58dac2..0000000 --- a/src/main/java/dev/hegel/Abi.java +++ /dev/null @@ -1,76 +0,0 @@ -package dev.hegel; - -/** - * Constants from the libhegel C ABI (hegel-c/include/hegel.h). - * - *

    Kept in sync with the engine header. These are implementation details: {@code public} only so - * the generators in {@code dev.hegel.generators} can reach them, not part of the user-facing API. - * - * @hidden - */ -public final class Abi { - private Abi() {} - - // Return codes (hegel_error_t). - public static final int OK = 0; - public static final int E_STOP_TEST = -1; - public static final int E_ASSUME = -2; - public static final int E_BACKEND = -3; - public static final int E_INVALID_HANDLE = -4; - public static final int E_INVALID_ARG = -5; - public static final int E_ALREADY_COMPLETE = -6; - public static final int E_NOT_COMPLETE = -7; - public static final int E_INTERNAL = -8; - - // Phases (bitmask for hegel_settings_phases). - public static final int PHASE_EXPLICIT = 1 << 0; - public static final int PHASE_REUSE = 1 << 1; - public static final int PHASE_GENERATE = 1 << 2; - public static final int PHASE_TARGET = 1 << 3; - public static final int PHASE_SHRINK = 1 << 4; - public static final int PHASE_ALL = 31; - - // Health-check suppression bitmask (hegel_settings_suppress_health_check). - public static final int HC_FILTER_TOO_MUCH = 1 << 0; - public static final int HC_TOO_SLOW = 1 << 1; - public static final int HC_TEST_CASES_TOO_LARGE = 1 << 2; - public static final int HC_LARGE_INITIAL_TEST_CASE = 1 << 3; - - // Span labels (argument to hegel_start_span). - public static final long LABEL_LIST = 1; - public static final long LABEL_LIST_ELEMENT = 2; - public static final long LABEL_SET = 3; - public static final long LABEL_SET_ELEMENT = 4; - public static final long LABEL_MAP = 5; - public static final long LABEL_MAP_ENTRY = 6; - public static final long LABEL_TUPLE = 7; - public static final long LABEL_ONE_OF = 8; - public static final long LABEL_OPTIONAL = 9; - public static final long LABEL_FIXED_DICT = 10; - public static final long LABEL_FLAT_MAP = 11; - public static final long LABEL_FILTER = 12; - public static final long LABEL_MAPPED = 13; - public static final long LABEL_SAMPLED_FROM = 14; - public static final long LABEL_ENUM_VARIANT = 15; - public static final long LABEL_STATEFUL = 16; - public static final long LABEL_COMPOSITE = 17; - - // hegel_mode_t. - public static final int MODE_TEST_RUN = 0; - public static final int MODE_SINGLE_TEST_CASE = 1; - - // hegel_verbosity_t. - public static final int VERBOSITY_QUIET = 0; - public static final int VERBOSITY_NORMAL = 1; - public static final int VERBOSITY_VERBOSE = 2; - public static final int VERBOSITY_DEBUG = 3; - - // hegel_status_t (argument to hegel_mark_complete). - public static final int STATUS_VALID = 0; - public static final int STATUS_INVALID = 1; - public static final int STATUS_OVERRUN = 2; - public static final int STATUS_INTERESTING = 3; - - // UINT64_MAX sentinel for "unbounded" collection size. - public static final long UNBOUNDED = -1L; // 0xFFFFFFFFFFFFFFFF as a Java long -} diff --git a/src/main/java/dev/hegel/Cbor.java b/src/main/java/dev/hegel/Cbor.java deleted file mode 100644 index 5394178..0000000 --- a/src/main/java/dev/hegel/Cbor.java +++ /dev/null @@ -1,114 +0,0 @@ -package dev.hegel; - -import com.upokecenter.cbor.CBORObject; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Encodes generator schemas to CBOR and decodes engine-produced values to native Java values. - * - *

    Schemas are built as {@link CBORObject} maps and encoded directly. Decoded values are - * normalised to a small set of Java types so generator parse functions can rely on them: - * - *

      - *
    • CBOR integer → {@link BigInteger} (engine integers can exceed {@code long}) - *
    • floating point (incl. half precision) → {@link Double} - *
    • boolean → {@link Boolean}, text → {@link String}, byte string → {@code byte[]} - *
    • array → {@link List}, map → {@link LinkedHashMap}, null → {@code null} - *
    • CBOR Tag 91 (WTF-8) → {@link String} (decode-only) - *
    - * - * @hidden - */ -public final class Cbor { - private Cbor() {} - - /** WTF-8 string tag the engine may wrap string values in. Decode-only; never sent. */ - private static final int TAG_WTF8 = 91; - - static byte[] encode(CBORObject schema) { - return schema.EncodeToBytes(); - } - - static Object decode(byte[] bytes) { - return convert(CBORObject.DecodeFromBytes(bytes)); - } - - static Object convert(CBORObject o) { - if (o.HasMostOuterTag(TAG_WTF8)) { - return new String(o.Untag().GetByteString(), StandardCharsets.UTF_8); - } - switch (o.getType()) { - case Integer: - return o.ToObject(BigInteger.class); - case FloatingPoint: - return o.AsDoubleValue(); - case Boolean: - return o.AsBoolean(); - case ByteString: - return o.GetByteString(); - case TextString: - return o.AsString(); - case Array: { - List list = new ArrayList<>(); - for (CBORObject e : o.getValues()) { - list.add(convert(e)); - } - return list; - } - case Map: { - Map map = new LinkedHashMap<>(); - for (Map.Entry e : o.getEntries()) { - map.put(convert(e.getKey()), convert(e.getValue())); - } - return map; - } - default: - if (o.isNull()) { - return null; - } - throw new HegelException("Unexpected CBOR value from engine: " + o.getType()); - } - } - - // --- typed extraction helpers used by generator parse functions --- - - public static long asLong(Object raw) { - return ((BigInteger) raw).longValueExact(); - } - - public static int asIndex(Object raw) { - return ((BigInteger) raw).intValueExact(); - } - - public static double asDouble(Object raw) { - return ((Number) raw).doubleValue(); - } - - public static float asFloat(Object raw) { - // The engine emits f32 draws as an f64 already rounded to f32 precision, so this cast is - // lossless and the same bit pattern round-trips. - return (float) ((Number) raw).doubleValue(); - } - - public static boolean asBoolean(Object raw) { - return (Boolean) raw; - } - - public static String asString(Object raw) { - return (String) raw; - } - - public static byte[] asBytes(Object raw) { - return (byte[]) raw; - } - - @SuppressWarnings("unchecked") - public static List asList(Object raw) { - return (List) raw; - } -} diff --git a/src/main/java/dev/hegel/DataSource.java b/src/main/java/dev/hegel/DataSource.java deleted file mode 100644 index e593170..0000000 --- a/src/main/java/dev/hegel/DataSource.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.hegel; - -import com.upokecenter.cbor.CBORObject; - -/** - * The per-test-case primitive surface that generators draw against. - * - *

    Generators depend on this interface rather than {@link Libhegel} directly, so they can be - * tested against a fake data source. Every method translates engine return codes: {@code STOP_TEST} - * becomes {@link StopTest}, an assumption rejection becomes {@link AssumeRejected}, and any other - * non-OK code becomes a {@link HegelException} carrying the engine's diagnostic. - */ -interface DataSource { - /** Draw one value described by {@code schema}, returning the decoded native value. */ - Object generate(CBORObject schema); - - void startSpan(long label); - - void stopSpan(boolean discard); - - long newCollection(long minSize, long maxSize); - - boolean collectionMore(long id); - - void collectionReject(long id, String why); - - void target(double value, String label); -} diff --git a/src/main/java/dev/hegel/Hegel.java b/src/main/java/dev/hegel/Hegel.java deleted file mode 100644 index d243480..0000000 --- a/src/main/java/dev/hegel/Hegel.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.hegel; - -import java.util.function.Consumer; - -/** - * Programmatic entry point for running property tests. - * - *

    The preferred way to write a property test is the {@link HegelTest} annotation on a JUnit 5 - * method. Use {@code Hegel.test} only when a setting must come from a runtime value (annotation - * attributes are compile-time constants) or when running a property outside a JUnit method. The - * body comes first; settings are an optional {@link Settings} value: - * - *

    {@code
    - * import static dev.hegel.Generators.integers;
    - *
    - * // default settings
    - * Hegel.test(tc -> {
    - *   int x = tc.draw(integers());
    - *   int y = tc.draw(integers());
    - *   assertEquals(x + y, y + x);
    - * });
    - *
    - * // with settings
    - * Hegel.test(tc -> { ... }, new Settings().testCases(500).seed(42));
    - * }
    - */ -public final class Hegel { - private Hegel() {} - - /** - * Run {@code body} as a property test with default settings. - * - * @param body the test body, run once per generated input - */ - public static void test(Consumer body) { - test(body, new Settings()); - } - - /** - * Run {@code body} as a property test under {@code settings}. - * - * @param body the test body, run once per generated input - * @param settings the run configuration - */ - public static void test(Consumer body, Settings settings) { - Runner.run(settings, body); - } -} diff --git a/src/main/java/dev/hegel/Libhegel.java b/src/main/java/dev/hegel/Libhegel.java deleted file mode 100644 index 7e76638..0000000 --- a/src/main/java/dev/hegel/Libhegel.java +++ /dev/null @@ -1,98 +0,0 @@ -package dev.hegel; - -import java.lang.foreign.MemorySegment; - -/** - * The libhegel binding surface, as a table of operations. - * - *

    Modelled as an interface so tests can substitute a fake binding that returns chosen return - * codes, exercising every error path without the real engine. The production implementation is - * {@link RealLibhegel}, which drives libhegel over the Foreign Function and Memory API. - * - *

    Opaque handles ({@code hegel_settings_t*}, {@code hegel_run_t*}, etc.) are passed as {@link - * MemorySegment}; callers treat them as opaque and never dereference them. - * - *

    Functions returning {@code int} return the raw libhegel return code; the caller translates it - * (see {@link LiveDataSource}) and reads {@link #lastErrorMessage()} immediately on a non-OK code. - * Strings the engine returns are copied out before this method returns, so they remain valid. - */ -interface Libhegel { - // Settings. - MemorySegment settingsNew(); - - void settingsFree(MemorySegment s); - - void settingsMode(MemorySegment s, int mode); - - void settingsTestCases(MemorySegment s, long n); - - void settingsVerbosity(MemorySegment s, int v); - - void settingsSeed(MemorySegment s, long seed, boolean hasSeed); - - void settingsDerandomize(MemorySegment s, boolean derandomize); - - void settingsReportMultipleFailures(MemorySegment s, boolean yes); - - /** - * {@code path == null} leaves the engine default; {@code ""} disables; otherwise sets the dir. - */ - void settingsDatabase(MemorySegment s, String path); - - void settingsDatabaseKey(MemorySegment s, String key); - - void settingsPhases(MemorySegment s, int mask); - - void settingsSuppressHealthCheck(MemorySegment s, int mask); - - // Run lifecycle. - MemorySegment runStart(MemorySegment settings); - - MemorySegment nextTestCase(MemorySegment run); - - MemorySegment runResult(MemorySegment run); - - void runFree(MemorySegment run); - - // Per-test-case primitives. Each returns the raw rc. - - /** - * Draw a value. On {@link Abi#OK} the decoded value bytes are copied into {@code out[0]}. On any - * non-OK code {@code out[0]} is left untouched. - */ - int generate(MemorySegment tc, byte[] schema, byte[][] out); - - int startSpan(MemorySegment tc, long label); - - int stopSpan(MemorySegment tc, boolean discard); - - int newCollection(MemorySegment tc, long minSize, long maxSize, long[] outId); - - int collectionMore(MemorySegment tc, long id, boolean[] outMore); - - int collectionReject(MemorySegment tc, long id, String why); - - int target(MemorySegment tc, double value, String label); - - int markComplete(MemorySegment tc, int status, String origin); - - boolean isFinalReplay(MemorySegment tc); - - // Results. - boolean resultPassed(MemorySegment result); - - long resultFailureCount(MemorySegment result); - - MemorySegment resultFailure(MemorySegment result, long index); - - String failurePanicMessage(MemorySegment failure); - - String failureDiagnostic(MemorySegment failure); - - String failureOrigin(MemorySegment failure); - - // Diagnostics. - String lastErrorMessage(); - - String version(); -} diff --git a/src/main/java/dev/hegel/LiveDataSource.java b/src/main/java/dev/hegel/LiveDataSource.java deleted file mode 100644 index 055e4dd..0000000 --- a/src/main/java/dev/hegel/LiveDataSource.java +++ /dev/null @@ -1,105 +0,0 @@ -package dev.hegel; - -import com.upokecenter.cbor.CBORObject; -import java.lang.foreign.MemorySegment; - -/** - * A {@link DataSource} backed by the real engine for one in-flight test case. - * - *

    Once the engine returns {@code STOP_TEST} (or an assumption is rejected) the source is marked - * {@code aborted}: value-producing primitives short-circuit by re-raising {@link StopTest} without - * touching libhegel, and {@link #stopSpan} becomes a no-op so span-closing {@code finally} blocks - * during unwinding do not call into a case that is already being torn down. - */ -final class LiveDataSource implements DataSource { - private final Libhegel lib; - private final MemorySegment tc; - private boolean aborted; - - LiveDataSource(Libhegel lib, MemorySegment tc) { - this.lib = lib; - this.tc = tc; - } - - boolean isAborted() { - return aborted; - } - - private void translate(int rc, String op) { - switch (rc) { - case Abi.OK: - return; - case Abi.E_STOP_TEST: - aborted = true; - throw new StopTest(); - case Abi.E_ASSUME: - aborted = true; - throw new AssumeRejected(); - default: - String msg = lib.lastErrorMessage(); - throw new HegelException("hegel_" + op + " failed (rc=" + rc + "): " + (msg == null ? "" : msg)); - } - } - - @Override - public Object generate(CBORObject schema) { - if (aborted) { - throw new StopTest(); - } - byte[][] out = new byte[1][]; - translate(lib.generate(tc, Cbor.encode(schema), out), "generate"); - return Cbor.decode(out[0]); - } - - @Override - public void startSpan(long label) { - if (aborted) { - throw new StopTest(); - } - translate(lib.startSpan(tc, label), "start_span"); - } - - @Override - public void stopSpan(boolean discard) { - if (aborted) { - return; - } - translate(lib.stopSpan(tc, discard), "stop_span"); - } - - @Override - public long newCollection(long minSize, long maxSize) { - if (aborted) { - throw new StopTest(); - } - long[] id = new long[1]; - translate(lib.newCollection(tc, minSize, maxSize, id), "new_collection"); - return id[0]; - } - - @Override - public boolean collectionMore(long id) { - if (aborted) { - throw new StopTest(); - } - boolean[] more = new boolean[1]; - translate(lib.collectionMore(tc, id, more), "collection_more"); - return more[0]; - } - - @Override - public void collectionReject(long id, String why) { - if (aborted) { - throw new StopTest(); - } - translate(lib.collectionReject(tc, id, why), "collection_reject"); - } - - @Override - public void target(double value, String label) { - if (aborted) { - throw new StopTest(); - } - translate(lib.target(tc, value, label), "target"); - } -} diff --git a/src/main/java/dev/hegel/Mode.java b/src/main/java/dev/hegel/Mode.java deleted file mode 100644 index 8a8b4c1..0000000 --- a/src/main/java/dev/hegel/Mode.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.hegel; - -/** Controls the test execution mode. */ -public enum Mode { - /** Run a full test: multiple test cases with shrinking and replay (the default). */ - TEST_RUN(Abi.MODE_TEST_RUN), - /** - * Run a single test case with no shrinking, replay, or database — pure data generation without - * property-testing overhead (an exploratory probe, useful for Antithesis-style workloads). - */ - SINGLE_TEST_CASE(Abi.MODE_SINGLE_TEST_CASE); - - final int code; - - Mode(int code) { - this.code = code; - } -} diff --git a/src/main/java/dev/hegel/RealLibhegel.java b/src/main/java/dev/hegel/RealLibhegel.java deleted file mode 100644 index 42593c0..0000000 --- a/src/main/java/dev/hegel/RealLibhegel.java +++ /dev/null @@ -1,365 +0,0 @@ -package dev.hegel; - -import static java.lang.foreign.ValueLayout.ADDRESS; -import static java.lang.foreign.ValueLayout.JAVA_BOOLEAN; -import static java.lang.foreign.ValueLayout.JAVA_BYTE; -import static java.lang.foreign.ValueLayout.JAVA_DOUBLE; -import static java.lang.foreign.ValueLayout.JAVA_INT; -import static java.lang.foreign.ValueLayout.JAVA_LONG; - -import java.lang.foreign.Arena; -import java.lang.foreign.FunctionDescriptor; -import java.lang.foreign.Linker; -import java.lang.foreign.MemorySegment; -import java.lang.foreign.SymbolLookup; -import java.lang.invoke.MethodHandle; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * The real libhegel binding, driving the C ABI over the Foreign Function and Memory API. - * - *

    Resolves every C symbol once and caches the resulting {@link MethodHandle}s, which are - * immutable after construction and safe to share across threads. Every call routes through {@link - * #invoke} so error translation and the one-place FFI try/catch live in a single method. - */ -final class RealLibhegel implements Libhegel { - private final Arena libArena; - - // libhegel borrows the database / database_key C-strings until hegel_run_start consumes the - // settings, so they must outlive their setter call. Each settings handle gets a confined arena - // (created in settingsNew, closed in settingsFree) that owns those buffers for its whole life. - private final Map settingsArenas = new ConcurrentHashMap<>(); - - private final MethodHandle settingsNew; - private final MethodHandle settingsFree; - private final MethodHandle settingsMode; - private final MethodHandle settingsTestCases; - private final MethodHandle settingsVerbosity; - private final MethodHandle settingsSeed; - private final MethodHandle settingsDerandomize; - private final MethodHandle settingsReportMultipleFailures; - private final MethodHandle settingsDatabase; - private final MethodHandle settingsDatabaseKey; - private final MethodHandle settingsPhases; - private final MethodHandle settingsSuppressHealthCheck; - private final MethodHandle runStart; - private final MethodHandle nextTestCase; - private final MethodHandle runResult; - private final MethodHandle runFree; - private final MethodHandle generate; - private final MethodHandle startSpan; - private final MethodHandle stopSpan; - private final MethodHandle newCollection; - private final MethodHandle collectionMore; - private final MethodHandle collectionReject; - private final MethodHandle target; - private final MethodHandle markComplete; - private final MethodHandle isFinalReplay; - private final MethodHandle resultPassed; - private final MethodHandle resultFailureCount; - private final MethodHandle resultFailure; - private final MethodHandle failurePanicMessage; - private final MethodHandle failureDiagnostic; - private final MethodHandle failureOrigin; - private final MethodHandle lastErrorMessage; - private final MethodHandle version; - - RealLibhegel(Path libraryPath) { - this.libArena = Arena.ofShared(); - Linker linker = Linker.nativeLinker(); - SymbolLookup lookup; - try { - lookup = SymbolLookup.libraryLookup(libraryPath, libArena); - } catch (IllegalArgumentException e) { - libArena.close(); - throw new HegelException("Failed to open libhegel at " + libraryPath + ": " + e.getMessage()); - } - - this.settingsNew = h(linker, lookup, "hegel_settings_new", FunctionDescriptor.of(ADDRESS)); - this.settingsFree = h(linker, lookup, "hegel_settings_free", FunctionDescriptor.ofVoid(ADDRESS)); - this.settingsMode = h(linker, lookup, "hegel_settings_mode", FunctionDescriptor.ofVoid(ADDRESS, JAVA_INT)); - this.settingsTestCases = - h(linker, lookup, "hegel_settings_test_cases", FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG)); - this.settingsVerbosity = - h(linker, lookup, "hegel_settings_verbosity", FunctionDescriptor.ofVoid(ADDRESS, JAVA_INT)); - this.settingsSeed = - h(linker, lookup, "hegel_settings_seed", FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_BOOLEAN)); - this.settingsDerandomize = - h(linker, lookup, "hegel_settings_derandomize", FunctionDescriptor.ofVoid(ADDRESS, JAVA_BOOLEAN)); - this.settingsReportMultipleFailures = h( - linker, - lookup, - "hegel_settings_report_multiple_failures", - FunctionDescriptor.ofVoid(ADDRESS, JAVA_BOOLEAN)); - this.settingsDatabase = - h(linker, lookup, "hegel_settings_database", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)); - this.settingsDatabaseKey = - h(linker, lookup, "hegel_settings_database_key", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)); - this.settingsPhases = h(linker, lookup, "hegel_settings_phases", FunctionDescriptor.ofVoid(ADDRESS, JAVA_INT)); - this.settingsSuppressHealthCheck = - h(linker, lookup, "hegel_settings_suppress_health_check", FunctionDescriptor.ofVoid(ADDRESS, JAVA_INT)); - this.runStart = h(linker, lookup, "hegel_run_start", FunctionDescriptor.of(ADDRESS, ADDRESS)); - this.nextTestCase = h(linker, lookup, "hegel_next_test_case", FunctionDescriptor.of(ADDRESS, ADDRESS)); - this.runResult = h(linker, lookup, "hegel_run_result", FunctionDescriptor.of(ADDRESS, ADDRESS)); - this.runFree = h(linker, lookup, "hegel_run_free", FunctionDescriptor.ofVoid(ADDRESS)); - this.generate = h( - linker, - lookup, - "hegel_generate", - FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS)); - this.startSpan = h(linker, lookup, "hegel_start_span", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG)); - this.stopSpan = h(linker, lookup, "hegel_stop_span", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_BOOLEAN)); - this.newCollection = h( - linker, - lookup, - "hegel_new_collection", - FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)); - this.collectionMore = h( - linker, lookup, "hegel_collection_more", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS)); - this.collectionReject = h( - linker, - lookup, - "hegel_collection_reject", - FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS)); - this.target = h(linker, lookup, "hegel_target", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_DOUBLE, ADDRESS)); - this.markComplete = - h(linker, lookup, "hegel_mark_complete", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); - this.isFinalReplay = - h(linker, lookup, "hegel_test_case_is_final_replay", FunctionDescriptor.of(JAVA_BOOLEAN, ADDRESS)); - this.resultPassed = h(linker, lookup, "hegel_run_result_passed", FunctionDescriptor.of(JAVA_BOOLEAN, ADDRESS)); - this.resultFailureCount = - h(linker, lookup, "hegel_run_result_failure_count", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); - this.resultFailure = - h(linker, lookup, "hegel_run_result_failure", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_LONG)); - this.failurePanicMessage = - h(linker, lookup, "hegel_failure_panic_message", FunctionDescriptor.of(ADDRESS, ADDRESS)); - this.failureDiagnostic = h(linker, lookup, "hegel_failure_diagnostic", FunctionDescriptor.of(ADDRESS, ADDRESS)); - this.failureOrigin = h(linker, lookup, "hegel_failure_origin", FunctionDescriptor.of(ADDRESS, ADDRESS)); - this.lastErrorMessage = h(linker, lookup, "hegel_last_error_message", FunctionDescriptor.of(ADDRESS)); - this.version = h(linker, lookup, "hegel_version", FunctionDescriptor.of(ADDRESS)); - } - - private static MethodHandle h(Linker linker, SymbolLookup lookup, String symbol, FunctionDescriptor desc) { - return linker.downcallHandle(findSymbol(lookup, symbol), desc); - } - - static MemorySegment findSymbol(SymbolLookup lookup, String symbol) { - return lookup.find(symbol) - .orElseThrow(() -> new HegelException("libhegel is missing symbol '" - + symbol - + "' (ABI/version mismatch). Rebuild or update the engine.")); - } - - /** Single point of FFI invocation and error wrapping. */ - static Object invoke(MethodHandle handle, Object... args) { - try { - return handle.invokeWithArguments(args); - } catch (RuntimeException e) { - throw e; - } catch (Throwable t) { - throw new HegelException("libhegel FFI call failed: " + t, t); - } - } - - private static MemorySegment cstr(Arena a, String s) { - return s == null ? MemorySegment.NULL : a.allocateFrom(s); - } - - static String readCString(MemorySegment ptr) { - if (ptr == null || ptr.address() == 0) { - return null; - } - return ptr.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.UTF_8); - } - - @Override - public MemorySegment settingsNew() { - MemorySegment s = (MemorySegment) invoke(settingsNew); - settingsArenas.put(s.address(), Arena.ofConfined()); - return s; - } - - @Override - public void settingsFree(MemorySegment s) { - Arena arena = settingsArenas.remove(s.address()); - invoke(settingsFree, s); - arena.close(); - } - - @Override - public void settingsMode(MemorySegment s, int mode) { - invoke(settingsMode, s, mode); - } - - @Override - public void settingsTestCases(MemorySegment s, long n) { - invoke(settingsTestCases, s, n); - } - - @Override - public void settingsVerbosity(MemorySegment s, int v) { - invoke(settingsVerbosity, s, v); - } - - @Override - public void settingsSeed(MemorySegment s, long seed, boolean hasSeed) { - invoke(settingsSeed, s, seed, hasSeed); - } - - @Override - public void settingsDerandomize(MemorySegment s, boolean derandomize) { - invoke(settingsDerandomize, s, derandomize); - } - - @Override - public void settingsReportMultipleFailures(MemorySegment s, boolean yes) { - invoke(settingsReportMultipleFailures, s, yes); - } - - @Override - public void settingsDatabase(MemorySegment s, String path) { - invoke(settingsDatabase, s, cstr(settingsArenas.get(s.address()), path)); - } - - @Override - public void settingsDatabaseKey(MemorySegment s, String key) { - invoke(settingsDatabaseKey, s, cstr(settingsArenas.get(s.address()), key)); - } - - @Override - public void settingsPhases(MemorySegment s, int mask) { - invoke(settingsPhases, s, mask); - } - - @Override - public void settingsSuppressHealthCheck(MemorySegment s, int mask) { - invoke(settingsSuppressHealthCheck, s, mask); - } - - @Override - public MemorySegment runStart(MemorySegment settings) { - return (MemorySegment) invoke(runStart, settings); - } - - @Override - public MemorySegment nextTestCase(MemorySegment run) { - return (MemorySegment) invoke(nextTestCase, run); - } - - @Override - public MemorySegment runResult(MemorySegment run) { - return (MemorySegment) invoke(runResult, run); - } - - @Override - public void runFree(MemorySegment run) { - invoke(runFree, run); - } - - @Override - public int generate(MemorySegment tc, byte[] schema, byte[][] out) { - Arena a = Arena.ofAuto(); - MemorySegment schemaSeg = a.allocate(schema.length); - MemorySegment.copy(schema, 0, schemaSeg, JAVA_BYTE, 0, schema.length); - MemorySegment outPtr = a.allocate(ADDRESS); - MemorySegment outLen = a.allocate(JAVA_LONG); - int rc = (Integer) invoke(generate, tc, schemaSeg, (long) schema.length, outPtr, outLen); - if (rc == Abi.OK) { - MemorySegment valPtr = outPtr.get(ADDRESS, 0); - long len = outLen.get(JAVA_LONG, 0); - out[0] = valPtr.reinterpret(len).toArray(JAVA_BYTE); - } - return rc; - } - - @Override - public int startSpan(MemorySegment tc, long label) { - return (Integer) invoke(startSpan, tc, label); - } - - @Override - public int stopSpan(MemorySegment tc, boolean discard) { - return (Integer) invoke(stopSpan, tc, discard); - } - - @Override - public int newCollection(MemorySegment tc, long minSize, long maxSize, long[] outId) { - MemorySegment idSeg = Arena.ofAuto().allocate(JAVA_LONG); - int rc = (Integer) invoke(newCollection, tc, minSize, maxSize, idSeg); - outId[0] = idSeg.get(JAVA_LONG, 0); // safe on error: callers check rc before use - return rc; - } - - @Override - public int collectionMore(MemorySegment tc, long id, boolean[] outMore) { - MemorySegment moreSeg = Arena.ofAuto().allocate(JAVA_BOOLEAN); - int rc = (Integer) invoke(collectionMore, tc, id, moreSeg); - // Read the (zero-initialised) out slot unconditionally: callers check the return code - // before using it, and the engine signals exhaustion on the following draw, not here. - outMore[0] = moreSeg.get(JAVA_BOOLEAN, 0); - return rc; - } - - @Override - public int collectionReject(MemorySegment tc, long id, String why) { - return (Integer) invoke(collectionReject, tc, id, cstr(Arena.ofAuto(), why)); - } - - @Override - public int target(MemorySegment tc, double value, String label) { - return (Integer) invoke(target, tc, value, cstr(Arena.ofAuto(), label)); - } - - @Override - public int markComplete(MemorySegment tc, int status, String origin) { - return (Integer) invoke(markComplete, tc, status, cstr(Arena.ofAuto(), origin)); - } - - @Override - public boolean isFinalReplay(MemorySegment tc) { - return (Boolean) invoke(isFinalReplay, tc); - } - - @Override - public boolean resultPassed(MemorySegment result) { - return (Boolean) invoke(resultPassed, result); - } - - @Override - public long resultFailureCount(MemorySegment result) { - return (Long) invoke(resultFailureCount, result); - } - - @Override - public MemorySegment resultFailure(MemorySegment result, long index) { - return (MemorySegment) invoke(resultFailure, result, index); - } - - @Override - public String failurePanicMessage(MemorySegment failure) { - return readCString((MemorySegment) invoke(failurePanicMessage, failure)); - } - - @Override - public String failureDiagnostic(MemorySegment failure) { - return readCString((MemorySegment) invoke(failureDiagnostic, failure)); - } - - @Override - public String failureOrigin(MemorySegment failure) { - return readCString((MemorySegment) invoke(failureOrigin, failure)); - } - - @Override - public String lastErrorMessage() { - return readCString((MemorySegment) invoke(lastErrorMessage)); - } - - @Override - public String version() { - return readCString((MemorySegment) invoke(version)); - } -} diff --git a/src/main/java/dev/hegel/Runner.java b/src/main/java/dev/hegel/Runner.java deleted file mode 100644 index 26f3234..0000000 --- a/src/main/java/dev/hegel/Runner.java +++ /dev/null @@ -1,255 +0,0 @@ -package dev.hegel; - -import java.io.PrintStream; -import java.lang.foreign.MemorySegment; -import java.util.HashMap; -import java.util.Map; -import java.util.function.BiConsumer; -import java.util.function.Consumer; - -/** - * Drives a single property test: builds the settings handle, runs the engine's case loop, maps each - * case outcome to a status, and turns the aggregated result into a pass or an {@link - * AssertionError} carrying the minimal falsifying example. - */ -final class Runner { - private Runner() {} - - /** - * Package prefixes treated as Hegel/JDK/test-framework infrastructure: {@link #originOf} skips - * frames in these to find the user frame that owns a failure (used as the shrink-dedup origin). - */ - private static final String[] INFRA_PREFIXES = { - "dev.hegel.", "org.junit.", "org.opentest4j.", "jdk.", "java.", "sun.", "com.sun." - }; - - static void run(Settings settings, Consumer body) { - run(Engine.get(), settings, body, System.getenv(), System.err); - } - - static void run( - Libhegel lib, Settings settings, Consumer body, Map env, PrintStream out) { - MemorySegment s = lib.settingsNew(); - try { - applySettings(lib, s, settings, env); - MemorySegment run = lib.runStart(s); - if (isNull(run)) { - throw backend(lib, "hegel_run_start"); - } - try { - // Default (report_multiple_failures off): keep the actual exception so we can rethrow it - // directly — best for debuggers and stack traces, and no origin tracking. Only the - // multiple-failures mode needs to stitch each captured message back onto a distinct, - // engine-deduped failure, so it alone builds the origin map. - Throwable[] captured = {null}; - Map panicByOrigin = settings.reportMultipleFailures ? new HashMap<>() : null; - loop(lib, run, settings.mode == Mode.SINGLE_TEST_CASE, body, out, (origin, e) -> { - captured[0] = e; - if (panicByOrigin != null) { - String message = describe(e); - out.println(message); - panicByOrigin.put(origin, message); - } - }); - MemorySegment result = lib.runResult(run); - if (isNull(result)) { - throw backend(lib, "hegel_run_result"); - } - if (!lib.resultPassed(result)) { - MemorySegment failure = lib.resultFailure(result, 0); - // A health check aborts the run regardless of mode; the engine reports it as a failure - // whose panic message is "FailedHealthCheck: ..." (the documented ABI format, stable - // across engine versions). Surface it as its own type, not a property failure. - String panic = lib.failurePanicMessage(failure); - if (panic != null && panic.startsWith("FailedHealthCheck")) { - throw new HealthCheckFailure(failureMessage(lib, failure)); - } - if (panicByOrigin != null) { - throw buildFailure(lib, result, panicByOrigin); - } - // Otherwise rethrow the body's own exception (always unchecked, from Consumer#accept). - if (captured[0] instanceof Error error) { - throw error; - } - if (captured[0] instanceof RuntimeException re) { - throw re; - } - // A failure with no Java exception to rethrow (e.g. the replay phase was disabled): - // surface the engine's own diagnostic. - throw new AssertionError(failureMessage(lib, failure)); - } - } finally { - lib.runFree(run); - } - } finally { - lib.settingsFree(s); - } - } - - private static void loop( - Libhegel lib, - MemorySegment run, - boolean single, - Consumer body, - PrintStream out, - BiConsumer onReportedFailure) { - while (true) { - MemorySegment tc = lib.nextTestCase(run); - if (isNull(tc)) { - String msg = lib.lastErrorMessage(); - if (msg != null && !msg.isEmpty()) { - throw new HegelException("hegel_next_test_case failed: " + msg); - } - return; - } - driveOneCase(lib, tc, single, body, out, onReportedFailure); - } - } - - static void driveOneCase( - Libhegel lib, - MemorySegment tc, - boolean single, - Consumer body, - PrintStream out, - BiConsumer onReportedFailure) { - boolean reporting = single || lib.isFinalReplay(tc); - TestCase testCase = new TestCase(new LiveDataSource(lib, tc), reporting, out); - int status; - String origin = null; - try { - body.accept(testCase); - status = Abi.STATUS_VALID; - } catch (AssumeRejected e) { - status = Abi.STATUS_INVALID; - } catch (StopTest e) { - status = Abi.STATUS_OVERRUN; - } catch (HegelException e) { - throw e; - } catch (Throwable e) { - status = Abi.STATUS_INTERESTING; - origin = originOf(e); - // Hand the failing exception to the run only on the case the engine actually reports — - // the final replay of the minimal example — exactly as hegel_test_case_is_final_replay - // is meant to gate (single-test-case mode has no replay, so its one case reports - // directly). The drawn values are printed separately by TestCase under this same flag, so - // the counterexample is shown whether or not the exception is rethrown. - if (reporting) { - onReportedFailure.accept(origin, e); - } - } - int rc = lib.markComplete(tc, status, origin); - if (rc != Abi.OK) { - throw new HegelException( - "hegel_mark_complete failed (rc=" + rc + "): " + nullToEmpty(lib.lastErrorMessage())); - } - } - - static void applySettings(Libhegel lib, MemorySegment s, Settings st, Map env) { - boolean ci = Settings.isCi(env); - lib.settingsTestCases(s, st.testCases); - lib.settingsVerbosity(s, st.verbosity.code); - if (st.hasSeed) { - lib.settingsSeed(s, st.seed, true); - } - lib.settingsDerandomize(s, st.derandomize != null ? st.derandomize : ci); - lib.settingsReportMultipleFailures(s, st.reportMultipleFailures); - if (st.mode != Mode.TEST_RUN) { - lib.settingsMode(s, st.mode.code); - } - if (st.suppressMask != 0) { - lib.settingsSuppressHealthCheck(s, st.suppressMask); - } - if (st.phasesMask != null) { - lib.settingsPhases(s, st.phasesMask); - } - - boolean dbEnabled; - switch (st.database.kind) { - case DISABLED: - lib.settingsDatabase(s, ""); - dbEnabled = false; - break; - case PATH: - lib.settingsDatabase(s, st.database.path); - dbEnabled = true; - break; - default: - if (ci) { - lib.settingsDatabase(s, ""); - dbEnabled = false; - } else { - dbEnabled = true; - } - break; - } - if (dbEnabled && st.name != null) { - lib.settingsDatabaseKey(s, st.name); - } - } - - static String originOf(Throwable e) { - for (StackTraceElement f : e.getStackTrace()) { - if (isUserFrame(f.getClassName())) { - return e.getClass().getSimpleName() + " at " + f.getFileName() + ":" + f.getLineNumber(); - } - } - return e.getClass().getName(); - } - - private static boolean isUserFrame(String className) { - for (String prefix : INFRA_PREFIXES) { - if (className.startsWith(prefix)) { - return false; - } - } - return true; - } - - private static String describe(Throwable e) { - String msg = e.getMessage(); - return msg == null ? e.getClass().getName() : e.getClass().getName() + ": " + msg; - } - - static AssertionError buildFailure(Libhegel lib, MemorySegment result, Map panicByOrigin) { - long n = lib.resultFailureCount(result); - StringBuilder sb = new StringBuilder(); - sb.append("Hegel found ").append(n).append(n == 1 ? " failing example:" : " distinct failing examples:"); - for (long i = 0; i < n; i++) { - MemorySegment failure = lib.resultFailure(result, i); - String diagnostic = lib.failureDiagnostic(failure); - String panic = lib.failurePanicMessage(failure); - String origin = lib.failureOrigin(failure); - sb.append("\n\n").append(pick(diagnostic, panic)); - String captured = panicByOrigin.get(origin); - if (captured != null) { - sb.append("\n ").append(captured); - } - } - return new AssertionError(sb.toString()); - } - - /** The engine's own message for a failure (full diagnostic, or panic message as a fallback). */ - private static String failureMessage(Libhegel lib, MemorySegment failure) { - return pick(lib.failureDiagnostic(failure), lib.failurePanicMessage(failure)); - } - - private static String pick(String diagnostic, String panic) { - if (diagnostic != null && !diagnostic.isEmpty()) { - return diagnostic; - } - return nullToEmpty(panic); - } - - private static String nullToEmpty(String s) { - return s == null ? "" : s; - } - - private static HegelException backend(Libhegel lib, String op) { - return new HegelException(op + " failed: " + nullToEmpty(lib.lastErrorMessage())); - } - - static boolean isNull(MemorySegment seg) { - return seg == null || seg.address() == 0; - } -} diff --git a/src/main/java/dev/hegel/Settings.java b/src/main/java/dev/hegel/Settings.java deleted file mode 100644 index 61584f7..0000000 --- a/src/main/java/dev/hegel/Settings.java +++ /dev/null @@ -1,220 +0,0 @@ -package dev.hegel; - -import java.util.Map; -import java.util.function.Consumer; - -/** - * Immutable configuration for a Hegel run, built with a fluent builder. - * - *

    Start from {@code new Settings()}, adjust with the fluent methods, and pass the result to - * {@link Hegel#test(java.util.function.Consumer, Settings)}: - * - *

    {@code
    - * Hegel.test(tc -> { ... }, new Settings().testCases(500).seed(42));
    - * }
    - * - *

    In CI (detected via {@code CI}/{@code GITHUB_ACTIONS}/... environment variables) runs default - * to deterministic ({@code derandomize}) and the example database is disabled, unless overridden. - */ -public final class Settings { - final long testCases; - final boolean hasSeed; - final long seed; - final Boolean derandomize; - final Database database; - final int suppressMask; - final Integer phasesMask; // null = leave the engine default (all phases) - final Verbosity verbosity; - final Mode mode; - // Default false: a single, directly-rethrown failure is far friendlier to debuggers and stack - // traces than an aggregated report — and that matters more in Java than elsewhere. - final boolean reportMultipleFailures; - final String name; - - /** Create settings with all defaults (100 test cases, all phases, normal verbosity). */ - public Settings() { - this(new Builder()); - } - - private Settings(Builder b) { - this.testCases = b.testCases; - this.hasSeed = b.hasSeed; - this.seed = b.seed; - this.derandomize = b.derandomize; - this.database = b.database; - this.suppressMask = b.suppressMask; - this.phasesMask = b.phasesMask; - this.verbosity = b.verbosity; - this.mode = b.mode; - this.reportMultipleFailures = b.reportMultipleFailures; - this.name = b.name; - } - - /** Return a copy of these settings with {@code mutator} applied to the changed fields. */ - private Settings with(Consumer mutator) { - Builder b = new Builder(); - b.testCases = testCases; - b.hasSeed = hasSeed; - b.seed = seed; - b.derandomize = derandomize; - b.database = database; - b.suppressMask = suppressMask; - b.phasesMask = phasesMask; - b.verbosity = verbosity; - b.mode = mode; - b.reportMultipleFailures = reportMultipleFailures; - b.name = name; - mutator.accept(b); - return new Settings(b); - } - - /** Mutable field holder used only to construct and copy {@link Settings}; holds the defaults. */ - private static final class Builder { - long testCases = 100; - boolean hasSeed = false; - long seed = 0L; - Boolean derandomize = null; - Database database = Database.unset(); - int suppressMask = 0; - Integer phasesMask = null; - Verbosity verbosity = Verbosity.NORMAL; - Mode mode = Mode.TEST_RUN; - boolean reportMultipleFailures = false; - String name = null; - } - - /** - * Set the maximum number of valid test cases to run (default 100). - * - * @param n the test-case budget - * @return a new settings instance - */ - public Settings testCases(long n) { - if (n <= 0) { - throw new IllegalArgumentException("testCases must be positive, got " + n); - } - return with(b -> b.testCases = n); - } - - /** - * Pin the RNG seed for a reproducible run. - * - * @param seed the seed - * @return a new settings instance - */ - public Settings seed(long seed) { - return with(b -> { - b.hasSeed = true; - b.seed = seed; - }); - } - - /** - * Force deterministic (or non-deterministic) input selection regardless of the CI default. - * - * @param derandomize whether to derive the seed deterministically - * @return a new settings instance - */ - public Settings derandomize(boolean derandomize) { - return with(b -> b.derandomize = derandomize); - } - - /** - * Configure the example database. Pass {@link Database#unset()} to keep the engine default, - * {@link Database#disabled()} to turn it off entirely, or {@link Database#path(String)} to use a - * specific directory. - * - * @param database the database setting - * @return a new settings instance - */ - public Settings database(Database database) { - return with(b -> b.database = database); - } - - /** - * Suppress the listed health checks. - * - * @param checks the checks to disable - * @return a new settings instance - */ - public Settings suppressHealthCheck(HealthCheck... checks) { - return with(b -> { - for (HealthCheck c : checks) { - b.suppressMask |= c.bit; - } - }); - } - - /** - * Enable only the listed phases; phases not listed are disabled. The default is all phases. With - * an empty argument list the run does nothing. - * - * @param phases the phases to enable - * @return a new settings instance - */ - public Settings phases(Phase... phases) { - return with(b -> { - b.phasesMask = 0; - for (Phase p : phases) { - b.phasesMask |= p.bit; - } - }); - } - - /** - * Set engine output verbosity. - * - * @param verbosity the verbosity level - * @return a new settings instance - */ - public Settings verbosity(Verbosity verbosity) { - return with(b -> b.verbosity = verbosity); - } - - /** - * Set the execution mode (default {@link Mode#TEST_RUN}). {@link Mode#SINGLE_TEST_CASE} runs - * exactly one test case with no shrinking, replay, or database (an exploratory probe). - * - * @param mode the execution mode - * @return a new settings instance - */ - public Settings mode(Mode mode) { - return with(b -> b.mode = mode); - } - - /** - * Control whether the run keeps searching for additional distinct failures after the first. - * Defaults to {@code false}: a single failure is rethrown directly (preserving its type and stack - * trace, which is friendlier to debuggers); enabling this instead aggregates the distinct - * failures into one report. - * - * @param yes whether to report multiple failures - * @return a new settings instance - */ - public Settings reportMultipleFailures(boolean yes) { - return with(b -> b.reportMultipleFailures = yes); - } - - /** - * Name this property (used to derive a stable database key). - * - * @param name the test name - * @return a new settings instance - */ - public Settings name(String name) { - return with(b -> b.name = name); - } - - /** Whether the current environment looks like CI. */ - static boolean isCi(Map env) { - return notEmpty(env.get("CI")) - || notEmpty(env.get("GITHUB_ACTIONS")) - || notEmpty(env.get("GITLAB_CI")) - || notEmpty(env.get("BUILDKITE")) - || notEmpty(env.get("CIRCLECI")); - } - - private static boolean notEmpty(String s) { - return s != null && !s.isEmpty(); - } -} diff --git a/src/main/java/dev/hegel/TestCase.java b/src/main/java/dev/hegel/TestCase.java deleted file mode 100644 index 98ac889..0000000 --- a/src/main/java/dev/hegel/TestCase.java +++ /dev/null @@ -1,179 +0,0 @@ -package dev.hegel; - -import com.upokecenter.cbor.CBORObject; -import java.io.PrintStream; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -/** - * The handle a property test body uses to draw values and steer the engine. - * - *

    An instance is supplied to the test body for each case the engine runs. Draw values with - * {@link #draw(Generator)}, reject uninteresting inputs with {@link #assume(boolean)}, attach debug - * context with {@link #note(String)}, and guide the search with {@link #target(double)}. - * - *

    On the engine's final replay of a minimal failing example, each top-level {@code draw} is - * printed as an assignment (for example {@code x = 42;}) so the counterexample is readable. - */ -public final class TestCase { - private final DataSource source; - private final boolean reporting; - private final PrintStream out; - private int drawDepth; - private int drawCounter; - - TestCase(DataSource source, boolean reporting, PrintStream out) { - this.source = source; - this.reporting = reporting; - this.out = out; - } - - /** - * Draw a value from {@code generator}. - * - * @param generator the generator to draw from - * @param the value type - * @return the generated value - */ - public T draw(Generator generator) { - return draw(generator, null); - } - - /** - * Draw a value, naming it {@code label} in the falsifying-example output. - * - * @param generator the generator to draw from - * @param label the variable name to show in counterexample output - * @param the value type - * @return the generated value - */ - public T draw(Generator generator, String label) { - boolean top = drawDepth == 0; - drawDepth++; - T value; - try { - value = generator.doDraw(this); - } finally { - drawDepth--; - } - if (top) { - drawCounter++; - if (reporting) { - String name = (label != null) ? label : "draw_" + drawCounter; - out.println(name + " = " + repr(value) + ";"); - } - } - return value; - } - - /** - * 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. - * - * @param condition the precondition that must hold - */ - public void assume(boolean condition) { - if (!condition) { - throw new AssumeRejected(); - } - } - - /** - * Record a debug message, shown only on the final replay of a failing case. - * - * @param message the message to record - */ - public void note(String message) { - if (reporting) { - out.println(message); - } - } - - /** - * Provide a score for the coverage-guided search; higher is treated as more interesting. - * - * @param value the observation - */ - public void target(double value) { - target(value, ""); - } - - /** - * Provide a labelled score for the coverage-guided search. - * - * @param value the observation - * @param label groups observations for multi-objective search - */ - public void target(double value, String label) { - source.target(value, label); - } - - // --- engine primitives used by generators in dev.hegel.generators (public for cross-package - // access; not part of the user-facing API) --- - - /** @hidden */ - public Object generateFromSchema(CBORObject schema) { - return source.generate(schema); - } - - /** @hidden */ - public void startSpan(long label) { - source.startSpan(label); - } - - /** @hidden */ - public void stopSpan(boolean discard) { - source.stopSpan(discard); - } - - /** @hidden */ - public long newCollection(long minSize, long maxSize) { - return source.newCollection(minSize, maxSize); - } - - /** @hidden */ - public boolean collectionMore(long id) { - return source.collectionMore(id); - } - - /** @hidden */ - public void collectionReject(long id, String why) { - source.collectionReject(id, why); - } - - static String repr(Object value) { - if (value == null) { - return "null"; - } - if (value instanceof String s) { - return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; - } - if (value instanceof byte[] b) { - return Arrays.toString(b); - } - if (value instanceof List list) { - StringBuilder sb = new StringBuilder("["); - for (int i = 0; i < list.size(); i++) { - if (i > 0) { - sb.append(", "); - } - sb.append(repr(list.get(i))); - } - return sb.append("]").toString(); - } - if (value instanceof Map map) { - StringBuilder sb = new StringBuilder("{"); - boolean first = true; - for (Map.Entry e : map.entrySet()) { - if (!first) { - sb.append(", "); - } - first = false; - sb.append(repr(e.getKey())).append(": ").append(repr(e.getValue())); - } - return sb.append("}").toString(); - } - return String.valueOf(value); - } -} diff --git a/src/main/java/dev/hegel/generators/BasicGenerator.java b/src/main/java/dev/hegel/generators/BasicGenerator.java deleted file mode 100644 index b224b49..0000000 --- a/src/main/java/dev/hegel/generators/BasicGenerator.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Generator; -import dev.hegel.TestCase; -import java.util.function.Function; - -/** - * A generator describable by a CBOR schema plus a client-side parse function. - * - *

    The engine generates a raw value from {@link #schema} in one call; {@link #parse} converts it - * to {@code T}. {@link #mapBasic} composes a new parse over the same schema, so chains of {@code - * map} stay on the single-draw path and shrink as well as the original. - * - * @hidden - */ -public final class BasicGenerator implements Generator { - public final CBORObject schema; - final Function parse; - - public BasicGenerator(CBORObject schema, Function parse) { - this.schema = schema; - this.parse = parse; - } - - @Override - public T doDraw(TestCase tc) { - return parse.apply(tc.generateFromSchema(schema)); - } - - @Override - public BasicGenerator asBasic() { - return this; - } - - public BasicGenerator mapBasic(Function f) { - Function oldParse = parse; - return new BasicGenerator<>(schema, raw -> f.apply(oldParse.apply(raw))); - } - - T parseRaw(Object raw) { - return parse.apply(raw); - } -} diff --git a/src/main/java/dev/hegel/generators/BooleanGenerator.java b/src/main/java/dev/hegel/generators/BooleanGenerator.java deleted file mode 100644 index 3f2f285..0000000 --- a/src/main/java/dev/hegel/generators/BooleanGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates {@code true} or {@code false}. Always basic (one engine call). - */ -public final class BooleanGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>(CBORObject.NewMap().Add("type", "boolean"), Cbor::asBoolean); - } -} diff --git a/src/main/java/dev/hegel/generators/ConstantGenerator.java b/src/main/java/dev/hegel/generators/ConstantGenerator.java deleted file mode 100644 index 8c0d021..0000000 --- a/src/main/java/dev/hegel/generators/ConstantGenerator.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Generator; - -/** - * Always generates the same value, ignoring the engine's choice. Always basic (one engine call). - * - * @param the constant value type - */ -public final class ConstantGenerator implements Generator { - private final T value; - - public ConstantGenerator(T value) { - this.value = value; - } - - /** @hidden */ - @Override - public BasicGenerator asBasic() { - CBORObject schema = CBORObject.NewMap().Add("type", "constant").Add("value", CBORObject.Null); - return new BasicGenerator<>(schema, raw -> value); - } -} diff --git a/src/main/java/dev/hegel/generators/DateGenerator.java b/src/main/java/dev/hegel/generators/DateGenerator.java deleted file mode 100644 index 2a72296..0000000 --- a/src/main/java/dev/hegel/generators/DateGenerator.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; -import java.time.LocalDate; - -/** - * Generates {@link LocalDate} values (the engine's {@code YYYY-MM-DD} output). Always basic (one - * engine call). - */ -public final class DateGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>( - CBORObject.NewMap().Add("type", "date"), raw -> LocalDate.parse(Cbor.asString(raw))); - } -} diff --git a/src/main/java/dev/hegel/generators/DomainGenerator.java b/src/main/java/dev/hegel/generators/DomainGenerator.java deleted file mode 100644 index 47cb1e3..0000000 --- a/src/main/java/dev/hegel/generators/DomainGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates syntactically valid domain names. Always basic (one engine call). - */ -public final class DomainGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>(CBORObject.NewMap().Add("type", "domain"), Cbor::asString); - } -} diff --git a/src/main/java/dev/hegel/generators/EmailGenerator.java b/src/main/java/dev/hegel/generators/EmailGenerator.java deleted file mode 100644 index 34b6759..0000000 --- a/src/main/java/dev/hegel/generators/EmailGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates syntactically valid email addresses. Always basic (one engine call). - */ -public final class EmailGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>(CBORObject.NewMap().Add("type", "email"), Cbor::asString); - } -} diff --git a/src/main/java/dev/hegel/generators/Floats.java b/src/main/java/dev/hegel/generators/Floats.java deleted file mode 100644 index 57060c5..0000000 --- a/src/main/java/dev/hegel/generators/Floats.java +++ /dev/null @@ -1,76 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; - -/** - * Shared validation and schema construction for the floating-point generators ({@link - * FloatGenerator} at width 32, {@link DoubleGenerator} at width 64). Bounds are carried as {@code - * double} for both; a 32-bit generator passes f32 bounds widened losslessly to f64. - */ -final class Floats { - private Floats() {} - - static void validate(String what, Double min, Double max, Boolean allowNan, Boolean allowInfinity) { - if (min != null && Double.isNaN(min)) { - throw new IllegalArgumentException(what + ": min must not be NaN"); - } - if (max != null && Double.isNaN(max)) { - throw new IllegalArgumentException(what + ": max must not be NaN"); - } - if (min != null && max != null && min > max) { - throw new IllegalArgumentException(what + ": min (" + min + ") > max (" + max + ")"); - } - boolean hasMin = min != null; - boolean hasMax = max != null; - if (Boolean.TRUE.equals(allowNan) && (hasMin || hasMax)) { - throw new IllegalArgumentException(what + ": cannot allow NaN together with a bound"); - } - if (Boolean.TRUE.equals(allowInfinity) && hasMin && hasMax) { - throw new IllegalArgumentException(what + ": cannot allow infinity with both bounds set"); - } - } - - /** - * Build the {@code float} schema. With no bounds, NaN and the infinities are allowed; setting any - * bound excludes NaN; setting both bounds also excludes the infinities. When neither NaN nor - * infinity is allowed, missing bounds are filled with the finite extremes of the target width so - * the engine never produces an out-of-range special. - */ - static CBORObject schema( - int width, - Double min, - Double max, - Boolean allowNan, - Boolean allowInfinity, - boolean excludeMin, - boolean excludeMax) { - boolean hasMin = min != null; - boolean hasMax = max != null; - boolean an = allowNan != null ? allowNan : (!hasMin && !hasMax); - boolean ai = allowInfinity != null ? allowInfinity : (!hasMin || !hasMax); - - CBORObject schema = CBORObject.NewMap() - .Add("type", "float") - .Add("exclude_min", excludeMin) - .Add("exclude_max", excludeMax) - .Add("allow_nan", an) - .Add("allow_infinity", ai) - .Add("width", width); - if (hasMin) { - schema.Add("min_value", min); - } - if (hasMax) { - schema.Add("max_value", max); - } - if (!an && !ai) { - double bound = width == 32 ? Float.MAX_VALUE : Double.MAX_VALUE; - if (!hasMin) { - schema.Add("min_value", -bound); - } - if (!hasMax) { - schema.Add("max_value", bound); - } - } - return schema; - } -} diff --git a/src/main/java/dev/hegel/generators/IpAddressGenerator.java b/src/main/java/dev/hegel/generators/IpAddressGenerator.java deleted file mode 100644 index 1db5f6d..0000000 --- a/src/main/java/dev/hegel/generators/IpAddressGenerator.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates IP address strings. By default produces a mix of IPv4 and IPv6; restrict to one family - * with the fluent {@link #v4()} / {@link #v6()} methods. Always basic (one engine call). - */ -public final class IpAddressGenerator implements Generator { - private final Integer version; // null = a mix of IPv4 and IPv6 - - public IpAddressGenerator(Integer version) { - this.version = version; - } - - /** - * @return a copy that generates only IPv4 addresses - */ - public IpAddressGenerator v4() { - return new IpAddressGenerator(4); - } - - /** - * @return a copy that generates only IPv6 addresses - */ - public IpAddressGenerator v6() { - return new IpAddressGenerator(6); - } - - private static CBORObject versionSchema(int version) { - return CBORObject.NewMap().Add("type", "ip_address").Add("version", version); - } - - /** @hidden */ - @Override - public BasicGenerator asBasic() { - if (version != null) { - return new BasicGenerator<>(versionSchema(version), Cbor::asString); - } - // No family pinned: draw a mix via one_of, whose raw value is [index, value]. - CBORObject schema = CBORObject.NewMap() - .Add("type", "one_of") - .Add("generators", CBORObject.NewArray().Add(versionSchema(4)).Add(versionSchema(6))); - return new BasicGenerator<>( - schema, raw -> Cbor.asString(Cbor.asList(raw).get(1))); - } -} diff --git a/src/main/java/dev/hegel/generators/MappedGenerator.java b/src/main/java/dev/hegel/generators/MappedGenerator.java deleted file mode 100644 index d6f6573..0000000 --- a/src/main/java/dev/hegel/generators/MappedGenerator.java +++ /dev/null @@ -1,46 +0,0 @@ -package dev.hegel.generators; - -import dev.hegel.Abi; -import dev.hegel.Generator; -import dev.hegel.TestCase; -import java.util.function.Function; - -/** - * Result of {@link Generator#map}. Preserves the efficient single-draw path: when the source is - * basic, {@link #asBasic} composes {@code f} over the source's parse (via {@link - * BasicGenerator#mapBasic}) so the chain stays a single engine call; otherwise the draw is bracketed - * in a {@code map} span. - * - * @param the source value type - * @param the mapped value type - */ -public final class MappedGenerator implements Generator { - private final Generator source; - private final Function f; - - public MappedGenerator(Generator source, Function f) { - this.source = source; - this.f = f; - } - - @Override - public U doDraw(TestCase tc) { - BasicGenerator basic = asBasic(); - if (basic != null) { - return basic.doDraw(tc); - } - tc.startSpan(Abi.LABEL_MAPPED); - try { - return f.apply(source.doDraw(tc)); - } finally { - tc.stopSpan(false); - } - } - - /** @hidden */ - @Override - public BasicGenerator asBasic() { - BasicGenerator sourceBasic = source.asBasic(); - return sourceBasic == null ? null : sourceBasic.mapBasic(f); - } -} diff --git a/src/main/java/dev/hegel/generators/OneOfGenerator.java b/src/main/java/dev/hegel/generators/OneOfGenerator.java deleted file mode 100644 index f5f804d..0000000 --- a/src/main/java/dev/hegel/generators/OneOfGenerator.java +++ /dev/null @@ -1,64 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; -import dev.hegel.Generator; -import dev.hegel.Generators; -import dev.hegel.TestCase; -import java.util.ArrayList; -import java.util.List; - -/** - * Chooses among alternative generators of the same type. - * - *

    Basic (one engine call) when every alternative is basic: the engine returns {@code [index, - * value]} and the chosen alternative's parse is applied. Otherwise composite: an index is drawn and - * the selected alternative is generated inside a ONE_OF span. - */ -public final class OneOfGenerator implements Generator { - private final List> options; - - public OneOfGenerator(List> options) { - if (options.isEmpty()) { - throw new IllegalArgumentException("oneOf requires at least one generator"); - } - this.options = List.copyOf(options); - } - - /** @hidden */ - @Override - public BasicGenerator asBasic() { - List> basics = new ArrayList<>(options.size()); - CBORObject schemas = CBORObject.NewArray(); - for (Generator g : options) { - BasicGenerator b = g.asBasic(); - if (b == null) { - return null; - } - basics.add(b); - schemas.Add(b.schema); - } - CBORObject schema = CBORObject.NewMap().Add("type", "one_of").Add("generators", schemas); - return new BasicGenerator<>(schema, raw -> { - List arr = Cbor.asList(raw); - int index = Cbor.asIndex(arr.get(0)); - return basics.get(index).parseRaw(arr.get(1)); - }); - } - - @Override - public T doDraw(TestCase tc) { - BasicGenerator basic = asBasic(); - if (basic != null) { - return basic.doDraw(tc); - } - tc.startSpan(Abi.LABEL_ONE_OF); - try { - int index = Generators.integers().min(0).max(options.size() - 1).doDraw(tc); - return options.get(index).doDraw(tc); - } finally { - tc.stopSpan(false); - } - } -} diff --git a/src/main/java/dev/hegel/generators/RegexGenerator.java b/src/main/java/dev/hegel/generators/RegexGenerator.java deleted file mode 100644 index f0c2aa3..0000000 --- a/src/main/java/dev/hegel/generators/RegexGenerator.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates strings matching a (Python-compatible) regular expression. Always basic (one engine - * call). - */ -public final class RegexGenerator implements Generator { - private final String pattern; - - public RegexGenerator(String pattern) { - this.pattern = pattern; - } - - /** @hidden */ - @Override - public BasicGenerator asBasic() { - CBORObject schema = CBORObject.NewMap().Add("type", "regex").Add("pattern", pattern); - return new BasicGenerator<>(schema, Cbor::asString); - } -} diff --git a/src/main/java/dev/hegel/generators/Sizes.java b/src/main/java/dev/hegel/generators/Sizes.java deleted file mode 100644 index 3246a72..0000000 --- a/src/main/java/dev/hegel/generators/Sizes.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.hegel.generators; - -import dev.hegel.Abi; - -/** Validation helper for collection size bounds. */ -final class Sizes { - private Sizes() {} - - static void validate(long minSize, long maxSize, String what) { - if (minSize < 0) { - throw new IllegalArgumentException(what + ": minSize must be >= 0, got " + minSize); - } - if (maxSize != Abi.UNBOUNDED && maxSize < minSize) { - throw new IllegalArgumentException( - what + ": maxSize (" + maxSize + ") must be >= minSize (" + minSize + ")"); - } - } -} diff --git a/src/main/java/dev/hegel/generators/TimeGenerator.java b/src/main/java/dev/hegel/generators/TimeGenerator.java deleted file mode 100644 index 849ebb8..0000000 --- a/src/main/java/dev/hegel/generators/TimeGenerator.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; -import java.time.LocalTime; - -/** - * Generates {@link LocalTime} values (the engine's {@code HH:MM:SS[.ffffff]} output). Always basic - * (one engine call). - */ -public final class TimeGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>( - CBORObject.NewMap().Add("type", "time"), raw -> LocalTime.parse(Cbor.asString(raw))); - } -} diff --git a/src/main/java/dev/hegel/generators/TupleGenerator.java b/src/main/java/dev/hegel/generators/TupleGenerator.java deleted file mode 100644 index 0621e87..0000000 --- a/src/main/java/dev/hegel/generators/TupleGenerator.java +++ /dev/null @@ -1,70 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Abi; -import dev.hegel.Cbor; -import dev.hegel.Generator; -import dev.hegel.TestCase; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Function; - -/** - * Generates fixed-length heterogeneous tuples. The element values are drawn in order and handed to - * an {@code assembler} that packs them into the user-facing type {@code T} (a {@code TupleN} record, - * or the raw {@code List} for the variadic factory). Basic when every element generator is - * basic; otherwise generates each element in order inside a TUPLE span. - * - * @param the assembled tuple type - */ -public final class TupleGenerator implements Generator { - private final List> elements; - private final Function, T> assembler; - - public TupleGenerator(List> elements, Function, T> assembler) { - this.elements = List.copyOf(elements); - this.assembler = assembler; - } - - /** @hidden */ - @Override - public BasicGenerator asBasic() { - List> basics = new ArrayList<>(elements.size()); - CBORObject schemas = CBORObject.NewArray(); - for (Generator g : elements) { - BasicGenerator b = g.asBasic(); - 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); - List out = new ArrayList<>(basics.size()); - for (int i = 0; i < basics.size(); i++) { - out.add(basics.get(i).parseRaw(rawList.get(i))); - } - return assembler.apply(out); - }); - } - - @Override - public T doDraw(TestCase tc) { - BasicGenerator basic = asBasic(); - if (basic != null) { - return basic.doDraw(tc); - } - tc.startSpan(Abi.LABEL_TUPLE); - try { - List out = new ArrayList<>(elements.size()); - for (Generator g : elements) { - out.add(g.doDraw(tc)); - } - return assembler.apply(out); - } finally { - tc.stopSpan(false); - } - } -} diff --git a/src/main/java/dev/hegel/generators/UrlGenerator.java b/src/main/java/dev/hegel/generators/UrlGenerator.java deleted file mode 100644 index 229a234..0000000 --- a/src/main/java/dev/hegel/generators/UrlGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates syntactically valid URLs. Always basic (one engine call). - */ -public final class UrlGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>(CBORObject.NewMap().Add("type", "url"), Cbor::asString); - } -} diff --git a/src/main/java/dev/hegel/generators/UuidGenerator.java b/src/main/java/dev/hegel/generators/UuidGenerator.java deleted file mode 100644 index f0b77f7..0000000 --- a/src/main/java/dev/hegel/generators/UuidGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.hegel.generators; - -import com.upokecenter.cbor.CBORObject; -import dev.hegel.Cbor; -import dev.hegel.Generator; - -/** - * Generates UUID strings. Always basic (one engine call). - */ -public final class UuidGenerator implements Generator { - /** @hidden */ - @Override - public BasicGenerator asBasic() { - return new BasicGenerator<>(CBORObject.NewMap().Add("type", "uuid"), Cbor::asString); - } -} diff --git a/src/main/java/dev/hegel/package-info.java b/src/main/java/dev/hegel/package-info.java deleted file mode 100644 index b835a90..0000000 --- a/src/main/java/dev/hegel/package-info.java +++ /dev/null @@ -1,216 +0,0 @@ -/// Property-based testing for Java, powered by the Hegel engine. -/// -/// Instead of writing tests with hand-picked example inputs, you describe a *property* that should -/// hold for all inputs and let Hegel generate inputs to try to falsify it. When it finds a failing -/// input it automatically **shrinks** it to a minimal counterexample. -/// -/// Hegel requires **Java 22+** (it uses the Foreign Function & Memory API) and -/// `--enable-native-access=ALL-UNNAMED` on the test JVM. The native engine, `libhegel`, is bundled -/// inside the jar for every supported platform and loaded automatically — nothing else to install. -/// -/// ## Your first test -/// -/// Use JUnit 5 as the runner. Annotate a method with {@link dev.hegel.HegelTest @HegelTest} and -/// give it a {@link dev.hegel.TestCase} parameter: -/// -/// ```java -/// import static dev.hegel.Generators.integers; -/// import static org.junit.jupiter.api.Assertions.assertEquals; -/// -/// import dev.hegel.HegelTest; -/// import dev.hegel.TestCase; -/// -/// class FirstTest { -/// @HegelTest -/// void integerSelfEquality(TestCase tc) { -/// int n = tc.draw(integers()); -/// assertEquals(n, n); // an integer always equals itself -/// } -/// } -/// ``` -/// -/// `@HegelTest` runs the method many times (100 by default). Each run receives a -/// {@link dev.hegel.TestCase}, whose {@link dev.hegel.TestCase#draw(dev.hegel.Generator) draw} -/// method produces a value from a generator. -/// -/// When you need a setting that can't be a compile-time constant, or want to run a property outside -/// a JUnit method, drive it programmatically with -/// {@link dev.hegel.Hegel#test(java.util.function.Consumer)} — the body comes first, with optional -/// {@link dev.hegel.Settings}: -/// -/// ```java -/// import static dev.hegel.Generators.integers; -/// import static org.junit.jupiter.api.Assertions.assertEquals; -/// -/// import dev.hegel.Hegel; -/// import org.junit.jupiter.api.Test; -/// -/// class CommutativityTest { -/// @Test -/// void additionCommutes() { -/// Hegel.test(tc -> { -/// int x = tc.draw(integers()); -/// int y = tc.draw(integers()); -/// assertEquals(x + y, y + x); -/// }); -/// } -/// } -/// ``` -/// -/// ## Understanding test output -/// -/// When a property fails, Hegel replays the minimal counterexample and prints each top-level -/// `draw` as an assignment: -/// -/// ```text -/// draw_1 = 50; -/// ``` -/// -/// Pass a label — `tc.draw(integers(), "n")` — to name the variable instead: -/// -/// ```text -/// n = 50; -/// ``` -/// -/// ## Generators -/// -/// {@link dev.hegel.Generators} provides a rich set of generators. Primitives include `integers`, -/// `longs`, `floats` (32-bit) and `doubles` (64-bit), `booleans`, `text`, and `binary`; collections -/// include `lists`, `sets`, and `maps`; and there are `tuples`, `oneOf`, `optional`, `sampledFrom`, -/// `just`, `durations` (`java.time.Duration`), and the temporal generators `dates`, `times`, and -/// `datetimes` (which produce `java.time.LocalDate`/`LocalTime`/`LocalDateTime`), plus format -/// generators (`emails`, `urls`, `ipAddresses`, `uuids`, `fromRegex`, …). -/// -/// For zone-aware datetimes, attach a timezone to a `datetimes()` generator: -/// -/// - {@link dev.hegel.generators.DateTimeGenerator#timezones timezones}: `datetimes().timezones(zoneIds())` -/// produces DST-aware `java.time.ZonedDateTime` values over the full range of zones the JVM -/// supports (see {@link dev.hegel.Generators#zoneIds()}); pin one with -/// `datetimes().timezones(just(ZoneId.of("Europe/London")))`. -/// - {@link dev.hegel.generators.DateTimeGenerator#offsets offsets}: `datetimes().offsets(zoneOffsets())` -/// produces fixed-offset `java.time.OffsetDateTime` values (see -/// {@link dev.hegel.Generators#zoneOffsets()}). -/// -/// The bound- and size-bearing generators are fluent builders that *are* the generator: -/// -/// ```java -/// tc.draw(integers().min(0).max(100)); // bounded ints -/// tc.draw(text().minSize(1).maxSize(10)); // short strings -/// tc.draw(doubles().min(0).max(1)); // a probability (64-bit) -/// tc.draw(floats().min(0).max(1)); // a 32-bit float in [0, 1] -/// tc.draw(lists(integers()).minSize(1).maxSize(5)); // 1–5 element lists -/// ``` -/// -/// ## Combinators -/// -/// Build new generators from existing ones (see {@link dev.hegel.Generator}): -/// -/// - {@link dev.hegel.Generator#map map} transforms each value (and keeps the efficient -/// single-draw path when possible): -/// ```java -/// Generator evens = integers().min(0).max(50).map(x -> x * 2); -/// ``` -/// - {@link dev.hegel.Generator#filter filter} keeps values matching a predicate (prefer -/// constraining over filtering when you can): -/// ```java -/// Generator big = integers().filter(x -> x > 1000); -/// ``` -/// - {@link dev.hegel.Generator#flatMap flatMap} makes one draw depend on another: -/// ```java -/// Generator> sized = integers().min(0).max(10).flatMap(n -> lists(booleans()).minSize(n).maxSize(n)); -/// ``` -/// - {@link dev.hegel.Generators#composite composite} builds a value imperatively from several -/// draws: -/// ```java -/// Generator pair = Generators.composite(tc -> new int[] { -/// tc.draw(integers()), tc.draw(integers()) -/// }); -/// ``` -/// -/// ## Recursive generators -/// -/// {@link dev.hegel.Generators#deferred() deferred} creates a forward reference so a generator can -/// refer to itself, enabling self-recursive (and mutually recursive) data such as trees: -/// -/// ```java -/// record Tree(Integer leaf, Tree left, Tree right) {} // leaf != null XOR children != null -/// -/// Deferred tree = Generators.deferred(); -/// Generator leaf = integers().map(n -> new Tree(n, null, null)); -/// Generator branch = -/// tuples(tree, tree).map(t -> new Tree(null, t.value1(), t.value2())); -/// tree.set(oneOf(leaf, branch)); // wire up the self-reference -/// Tree t = tc.draw(tree); -/// ``` -/// -/// The engine's size control keeps generated structures finite. Drawing before -/// {@link dev.hegel.generators.Deferred#set set} is called fails. -/// -/// ## Control functions -/// -/// Inside a test body you can steer the engine via {@link dev.hegel.TestCase}: -/// -/// - {@link dev.hegel.TestCase#assume(boolean) assume} discards the current input if a precondition -/// does not hold. -/// - {@link dev.hegel.TestCase#note(String) note} records a message shown only on the final replay -/// of a failing case. -/// - {@link dev.hegel.TestCase#target(double, String) target} reports a score so the search can -/// hill-climb toward interesting inputs. -/// -/// ```java -/// @HegelTest -/// void divisionRoundTrips(TestCase tc) { -/// int x = tc.draw(integers().min(1).max(1000)); -/// int y = tc.draw(integers().min(1).max(1000)); -/// tc.assume(y != 0); -/// tc.note("testing " + x + " * " + y + " / " + y); -/// assertEquals(x, (x * y) / y); -/// } -/// ``` -/// -/// ## Settings -/// -/// Configure a run by passing a {@link dev.hegel.Settings} value (built with {@code new Settings()} -/// and the fluent setters) to -/// {@link dev.hegel.Hegel#test(java.util.function.Consumer, dev.hegel.Settings)}, or with attributes -/// on {@link dev.hegel.HegelTest @HegelTest}: -/// -/// ```java -/// Hegel.test( -/// tc -> { /* ... */ }, -/// new Settings() -/// .testCases(500) // run more inputs -/// .seed(42)); // reproducible run -/// -/// @HegelTest(testCases = 1000, seed = 42) -/// void thorough(TestCase tc) { /* ... */ } -/// ``` -/// -/// Other settings include `derandomize`, {@link dev.hegel.Settings#database(dev.hegel.Database) -/// database}, `suppressHealthCheck`, -/// `verbosity`, `mode`, and {@link dev.hegel.Settings#phases(dev.hegel.Phase...) phases}. -/// In CI (detected automatically) runs default to deterministic and the example database is -/// disabled. If a health check fires — for example, your generators reject almost every input — -/// Hegel aborts the run and throws {@link dev.hegel.HealthCheckFailure} (distinct from a property's -/// own failure); pass the relevant {@link dev.hegel.HealthCheck} to `suppressHealthCheck` if the -/// behaviour is intentional. -/// -/// ## Deriving generators from types -/// -/// Hegel can build a generator for a record, enum, or supported scalar/collection type by -/// reflection via {@link dev.hegel.Generators#forType(Class) forType} and -/// {@link dev.hegel.Generators#records(Class) records}: -/// -/// ```java -/// record Point(int x, int y) {} -/// enum Color { RED, GREEN, BLUE } -/// -/// @HegelTest -/// void derived(TestCase tc) { -/// Point p = tc.draw(Generators.forType(Point.class)); -/// Color c = tc.draw(Generators.forType(Color.class)); -/// // Override a single component: -/// Point bounded = tc.draw(Generators.records(Point.class).with("x", integers().min(0).max(9))); -/// } -/// ``` -package dev.hegel; diff --git a/src/test/java/dev/hegel/BindingErrorPathsTest.java b/src/test/java/dev/hegel/BindingErrorPathsTest.java deleted file mode 100644 index d2dd925..0000000 --- a/src/test/java/dev/hegel/BindingErrorPathsTest.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.hegel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.upokecenter.cbor.CBORObject; -import org.junit.jupiter.api.Test; - -/** Covers {@link LiveDataSource} return-code translation and the abort short-circuit. */ -class BindingErrorPathsTest { - private static final CBORObject SCHEMA = CBORObject.NewMap().Add("type", "boolean"); - - private LiveDataSource source(FakeLibhegel fake) { - return new LiveDataSource(fake, FakeLibhegel.TC); - } - - @Test - void generateStopTestUnwindsAndAborts() { - FakeLibhegel fake = new FakeLibhegel(); - fake.generateRc = Abi.E_STOP_TEST; - LiveDataSource ds = source(fake); - assertThrows(StopTest.class, () -> ds.generate(SCHEMA)); - assertTrue(ds.isAborted()); - // Subsequent value-producing primitives short-circuit to StopTest without touching libhegel. - assertThrows(StopTest.class, () -> ds.generate(SCHEMA)); - assertThrows(StopTest.class, () -> ds.startSpan(Abi.LABEL_LIST)); - assertThrows(StopTest.class, () -> ds.newCollection(0, 1)); - assertThrows(StopTest.class, () -> ds.collectionMore(1)); - assertThrows(StopTest.class, () -> ds.collectionReject(1, "x")); - assertThrows(StopTest.class, () -> ds.target(1.0, "l")); - // stopSpan is a no-op once aborted (used by span-closing finally blocks). - ds.stopSpan(false); - } - - @Test - void generateAssumeUnwinds() { - FakeLibhegel fake = new FakeLibhegel(); - fake.generateRc = Abi.E_ASSUME; - LiveDataSource ds = source(fake); - assertThrows(AssumeRejected.class, () -> ds.generate(SCHEMA)); - assertTrue(ds.isAborted()); - } - - @Test - void backendErrorBecomesHegelException() { - FakeLibhegel fake = new FakeLibhegel(); - fake.generateRc = Abi.E_BACKEND; - fake.lastError = "boom"; - LiveDataSource ds = source(fake); - HegelException e = assertThrows(HegelException.class, () -> ds.generate(SCHEMA)); - assertTrue(e.getMessage().contains("boom")); - assertFalse(ds.isAborted()); - } - - @Test - void startSpanStopTest() { - FakeLibhegel fake = new FakeLibhegel(); - fake.startSpanRc = Abi.E_STOP_TEST; - assertThrows(StopTest.class, () -> source(fake).startSpan(Abi.LABEL_LIST)); - } - - @Test - void stopSpanBackendError() { - FakeLibhegel fake = new FakeLibhegel(); - fake.stopSpanRc = Abi.E_INVALID_HANDLE; - assertThrows(HegelException.class, () -> source(fake).stopSpan(false)); - } - - @Test - void newCollectionReturnsIdThenStopTest() { - FakeLibhegel fake = new FakeLibhegel(); - fake.collectionId = 42; - assertEquals(42, source(fake).newCollection(0, 5)); - - fake.newCollectionRc = Abi.E_STOP_TEST; - assertThrows(StopTest.class, () -> source(fake).newCollection(0, 5)); - } - - @Test - void collectionMoreReturnsValueThenError() { - FakeLibhegel fake = new FakeLibhegel(); - fake.moreSequence = new boolean[] {true, false}; - LiveDataSource ds = source(fake); - assertTrue(ds.collectionMore(1)); - assertFalse(ds.collectionMore(1)); - - FakeLibhegel bad = new FakeLibhegel(); - bad.collectionMoreRc = Abi.E_BACKEND; - assertThrows(HegelException.class, () -> source(bad).collectionMore(1)); - } - - @Test - void collectionRejectAndTargetPropagate() { - FakeLibhegel reject = new FakeLibhegel(); - reject.collectionRejectRc = Abi.E_STOP_TEST; - assertThrows(StopTest.class, () -> source(reject).collectionReject(1, "dup")); - - FakeLibhegel okReject = new FakeLibhegel(); - source(okReject).collectionReject(1, "dup"); // OK path - - FakeLibhegel target = new FakeLibhegel(); - target.targetRc = Abi.E_ASSUME; - assertThrows(AssumeRejected.class, () -> source(target).target(1.0, "l")); - - FakeLibhegel okTarget = new FakeLibhegel(); - source(okTarget).target(2.0, "l"); // OK path - } -} diff --git a/src/test/java/dev/hegel/CborTest.java b/src/test/java/dev/hegel/CborTest.java deleted file mode 100644 index 5982c9a..0000000 --- a/src/test/java/dev/hegel/CborTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package dev.hegel; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.upokecenter.cbor.CBORObject; -import java.math.BigInteger; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class CborTest { - @Test - void decodesIntegersAsBigIntegerBothSigns() { - assertEquals(BigInteger.valueOf(42), Cbor.convert(CBORObject.FromObject(42))); - assertEquals(BigInteger.valueOf(-7), Cbor.convert(CBORObject.FromObject(-7))); - } - - @Test - void decodesFloatsBooleansStringsBytes() { - assertEquals(1.5, (Double) Cbor.convert(CBORObject.FromObject(1.5)), 0.0); - assertEquals(true, Cbor.convert(CBORObject.FromObject(true))); - assertEquals("hi", Cbor.convert(CBORObject.FromObject("hi"))); - assertArrayEquals(new byte[] {1, 2}, (byte[]) Cbor.convert(CBORObject.FromObject(new byte[] {1, 2}))); - } - - @Test - void decodesNull() { - assertNull(Cbor.convert(CBORObject.Null)); - } - - @Test - void decodesArraysAndMapsRecursively() { - Object arr = Cbor.convert(CBORObject.NewArray().Add(1).Add("x")); - assertEquals(List.of(BigInteger.ONE, "x"), arr); - - Object map = Cbor.convert(CBORObject.NewMap().Add("k", 2)); - assertEquals(Map.of("k", BigInteger.TWO), map); - } - - @Test - void decodesTag91AsString() { - CBORObject tagged = CBORObject.FromObjectAndTag( - CBORObject.FromObject("wtf8".getBytes(java.nio.charset.StandardCharsets.UTF_8)), 91); - assertEquals("wtf8", Cbor.convert(tagged)); - } - - @Test - void rejectsUnexpectedSimpleValue() { - // 0xF7 = CBOR "undefined" — neither null nor a value we expect from the engine. - CBORObject undefined = CBORObject.DecodeFromBytes(new byte[] {(byte) 0xF7}); - assertThrows(HegelException.class, () -> Cbor.convert(undefined)); - } - - @Test - void encodeAndDecodeRoundTrip() { - byte[] bytes = Cbor.encode(CBORObject.NewArray().Add(1).Add(2)); - assertEquals(List.of(BigInteger.ONE, BigInteger.TWO), Cbor.decode(bytes)); - } - - @Test - void typedHelpers() { - assertEquals(5L, Cbor.asLong(BigInteger.valueOf(5))); - assertEquals(3, Cbor.asIndex(BigInteger.valueOf(3))); - assertEquals(2.0, Cbor.asDouble(BigInteger.valueOf(2)), 0.0); - assertEquals(2.5, Cbor.asDouble(2.5), 0.0); - assertTrue(Cbor.asBoolean(Boolean.TRUE)); - assertEquals("s", Cbor.asString("s")); - assertArrayEquals(new byte[] {9}, Cbor.asBytes(new byte[] {9})); - assertEquals(List.of(1), Cbor.asList(List.of(1))); - } -} diff --git a/src/test/java/dev/hegel/CoverageTest.java b/src/test/java/dev/hegel/CoverageTest.java deleted file mode 100644 index 759eb4e..0000000 --- a/src/test/java/dev/hegel/CoverageTest.java +++ /dev/null @@ -1,237 +0,0 @@ -package dev.hegel; - -import static dev.hegel.Generators.doubles; -import static dev.hegel.Generators.floats; -import static dev.hegel.Generators.integers; -import static dev.hegel.Generators.maps; -import static dev.hegel.Generators.sets; -import static dev.hegel.Generators.text; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** Targeted tests closing remaining coverage branches. */ -class CoverageTest { - private static final Map NO_CI = Map.of(); - - // --- Settings.isCi --- - @Test - void isCiDetectsEachProvider() { - assertFalse(Settings.isCi(Map.of())); - assertTrue(Settings.isCi(Map.of("CI", "true"))); - assertTrue(Settings.isCi(Map.of("GITHUB_ACTIONS", "true"))); - assertTrue(Settings.isCi(Map.of("GITLAB_CI", "true"))); - assertTrue(Settings.isCi(Map.of("BUILDKITE", "true"))); - assertTrue(Settings.isCi(Map.of("CIRCLECI", "true"))); - assertFalse(Settings.isCi(Map.of("CI", ""))); - } - - // --- BasicGenerator is its own basic representation --- - @Test - void basicGeneratorAsBasicIsIdentity() { - var basic = integers().asBasic(); - assertSame(basic, basic.asBasic()); - } - - // --- FloatGenerator / DoubleGenerator schema branches (no engine needed) --- - @Test - void floatSchemaVariants() { - assertNotNull(floats().asBasic()); - assertNotNull(floats().allowNan(true).asBasic()); - assertNotNull(floats().allowInfinity(true).asBasic()); - assertNotNull(floats().allowNan(false).allowInfinity(false).asBasic()); - assertNotNull(floats().min(0).max(1).asBasic()); - assertNotNull(floats().min(-2).asBasic()); - assertNotNull(floats().max(2).excludeMin(true).excludeMax(true).asBasic()); - } - - @Test - void doubleSchemaVariants() { - assertNotNull(doubles().asBasic()); - assertNotNull(doubles().allowNan(true).asBasic()); - assertNotNull(doubles().allowInfinity(true).asBasic()); - assertNotNull(doubles().allowNan(false).allowInfinity(false).asBasic()); - assertNotNull(doubles().min(0).max(1).asBasic()); - assertNotNull(doubles().min(-2).asBasic()); - assertNotNull(doubles().max(2).excludeMin(true).excludeMax(true).asBasic()); - } - - // --- Collection schema basicness branches --- - @Test - void setAndDictBasicnessBranches() { - assertNotNull(sets(integers()).minSize(1).maxSize(3).asBasic()); // bounded, basic - assertNotNull(maps(integers(), integers()).minSize(1).maxSize(3).asBasic()); // bounded, basic - // key basic, value non-basic -> not basic - assertNull(maps(integers(), integers().filter(x -> x > 0)).asBasic()); - // key non-basic -> not basic - assertNull(maps(integers().filter(x -> x > 0), integers()).asBasic()); - } - - @Test - void textExcludeCategoriesAlreadyHasCs() { - assertNotNull(text().excludeCategories("Cs").asBasic()); - assertNotNull(text().excludeCategories("Cc").asBasic()); - } - - @Test - void generatorWithoutOverridesFails() { - // A Generator overriding neither doDraw() nor asBasic() has no draw path. - Generator g = new Generator<>() {}; - assertThrows(IllegalStateException.class, () -> g.doDraw(null)); - } - - // --- HegelTestExtension static helpers --- - static final class Holder { - @HegelTest(seed = 5) - void seeded(TestCase tc) {} - - @HegelTest - void unseeded(TestCase tc) {} - - void plain(TestCase tc) {} - - @HegelTest( - derandomize = OptBoolean.TRUE, - phases = {Phase.GENERATE}, - suppressHealthCheck = {HealthCheck.TOO_SLOW}, - mode = Mode.SINGLE_TEST_CASE, - reportMultipleFailures = true, - name = "custom") - void configured(TestCase tc) {} - - @HegelTest( - derandomize = OptBoolean.FALSE, - phases = {}, - database = Database.DISABLED) - void derandomFalseEmptyPhases(TestCase tc) {} - - @HegelTest(database = "/tmp/hdb") - void customDb(TestCase tc) {} - } - - @Test - void hegelTestHelpers() throws Exception { - Method seeded = Holder.class.getDeclaredMethod("seeded", TestCase.class); - Method unseeded = Holder.class.getDeclaredMethod("unseeded", TestCase.class); - Method plain = Holder.class.getDeclaredMethod("plain", TestCase.class); - - assertTrue(HegelTestExtension.isHegelTest(seeded)); - assertFalse(HegelTestExtension.isHegelTest(plain)); - assertFalse(HegelTestExtension.isHegelTest(null)); - - assertTrue(HegelTestExtension.isTestCaseParam(TestCase.class)); - assertFalse(HegelTestExtension.isTestCaseParam(String.class)); - - Settings withSeed = HegelTestExtension.settingsFrom(seeded.getAnnotation(HegelTest.class), "s"); - assertEquals(5L, withSeed.seed); - assertTrue(withSeed.hasSeed); - - // Defaults: no seed, no derandomize override, engine-default phases, default database, method - // name as the property name. - Settings noSeed = HegelTestExtension.settingsFrom(unseeded.getAnnotation(HegelTest.class), "u"); - assertFalse(noSeed.hasSeed); - assertNull(noSeed.derandomize); - assertNull(noSeed.phasesMask); - assertEquals(Database.Kind.UNSET, noSeed.database.kind); - assertEquals(0, noSeed.suppressMask); - assertEquals(Mode.TEST_RUN, noSeed.mode); - assertFalse(noSeed.reportMultipleFailures); - assertEquals("u", noSeed.name); - - // Fully-configured: derandomize forced on, a single explicit phase, a suppressed check, single - // case and multi-failure modes, and a name override. - Method configured = Holder.class.getDeclaredMethod("configured", TestCase.class); - Settings c = HegelTestExtension.settingsFrom(configured.getAnnotation(HegelTest.class), "ignored"); - assertEquals(Boolean.TRUE, c.derandomize); - assertEquals(Integer.valueOf(Phase.GENERATE.bit), c.phasesMask); - assertEquals(HealthCheck.TOO_SLOW.bit, c.suppressMask); - assertEquals(Mode.SINGLE_TEST_CASE, c.mode); - assertTrue(c.reportMultipleFailures); - assertEquals("custom", c.name); - - // derandomize forced off, an explicitly empty phase set (runs nothing — distinct from the - // all-phases default), and the database disabled. - Method emptyPhases = Holder.class.getDeclaredMethod("derandomFalseEmptyPhases", TestCase.class); - Settings e = HegelTestExtension.settingsFrom(emptyPhases.getAnnotation(HegelTest.class), "e"); - assertEquals(Boolean.FALSE, e.derandomize); - assertEquals(Integer.valueOf(0), e.phasesMask); - assertEquals(Database.Kind.DISABLED, e.database.kind); - - // A custom (compile-time) database path. - Method customDb = Holder.class.getDeclaredMethod("customDb", TestCase.class); - Settings d = HegelTestExtension.settingsFrom(customDb.getAnnotation(HegelTest.class), "d"); - assertEquals(Database.Kind.PATH, d.database.kind); - assertEquals("/tmp/hdb", d.database.path); - } - - // --- Runner residual branches via fake --- - private static void run(FakeLibhegel fake, java.util.function.Consumer body) { - Runner.run( - fake, - new Settings().database(Database.disabled()), - body, - NO_CI, - new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)); - } - - @Test - void nextTestCaseNullWithNullLastErrorCompletesNormally() { - FakeLibhegel fake = new FakeLibhegel(); - fake.caseCount = 0; - fake.doneLastError = null; // exercises the msg == null path in the loop - run(fake, tc -> {}); - assertTrue(fake.markedStatuses.isEmpty()); - } - - @Test - void describeHandlesNullMessage() { - // describe() is exercised only by the multiple-failures report path. - FakeLibhegel fake = new FakeLibhegel(); - fake.finalReplay = true; - Runner.run( - fake, - new Settings().database(Database.disabled()).reportMultipleFailures(true), - tc -> { - throw new IllegalStateException(); // null message - }, - NO_CI, - new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)); - assertEquals(Abi.STATUS_INTERESTING, fake.markedStatuses.get(0)); - } - - @Test - void failureWithNullDiagnosticAndPanicUsesEmpty() { - FakeLibhegel fake = new FakeLibhegel(); - fake.passed = false; - FakeLibhegel.Failure f = new FakeLibhegel.Failure(); - f.diagnostic = null; - f.panic = null; - fake.failures.add(f); - assertThrows(AssertionError.class, () -> run(fake, tc -> {})); - } - - @Test - void backendErrorWithNullMessage() { - FakeLibhegel fake = new FakeLibhegel(); - fake.generateRc = Abi.E_BACKEND; - fake.lastError = null; - LiveDataSource ds = new LiveDataSource(fake, FakeLibhegel.TC); - assertThrows(HegelException.class, () -> ds.generate(com.upokecenter.cbor.CBORObject.NewMap())); - } - - @Test - void isNullHandlesJavaNull() { - assertTrue(Runner.isNull(null)); - } -} diff --git a/src/test/java/dev/hegel/FakeLibhegel.java b/src/test/java/dev/hegel/FakeLibhegel.java deleted file mode 100644 index 5efa95e..0000000 --- a/src/test/java/dev/hegel/FakeLibhegel.java +++ /dev/null @@ -1,223 +0,0 @@ -package dev.hegel; - -import com.upokecenter.cbor.CBORObject; -import java.lang.foreign.MemorySegment; -import java.util.ArrayList; -import java.util.List; - -/** - * A configurable in-memory {@link Libhegel} for exercising error paths and runner logic without the - * native engine. Every per-case primitive's return code is a public field defaulting to {@link - * Abi#OK}; set one to a negative code to drive a specific translation path. - */ -final class FakeLibhegel implements Libhegel { - // Opaque handle sentinels (non-null addresses). - static final MemorySegment SETTINGS = MemorySegment.ofAddress(0x100); - static final MemorySegment RUN = MemorySegment.ofAddress(0x200); - static final MemorySegment TC = MemorySegment.ofAddress(0x300); - static final MemorySegment RESULT = MemorySegment.ofAddress(0x400); - - String lastError = "fake error"; - String version = "0.0.0-fake"; - - // Run-loop control. - int caseCount = 1; // how many test cases nextTestCase yields - private int casesServed; - String doneLastError = ""; // lastError reported on normal completion - boolean nextTestCaseError; // if true, nextTestCase returns NULL with lastError set - boolean runStartNull; - boolean runResultNull; - boolean finalReplay; - boolean passed = true; - - // Recorded outcomes. - final List markedStatuses = new ArrayList<>(); - final List markedOrigins = new ArrayList<>(); - int markCompleteRc = Abi.OK; - - // Per-primitive return codes. - int generateRc = Abi.OK; - byte[] generateValue = CBORObject.FromObject(0).EncodeToBytes(); - int startSpanRc = Abi.OK; - int stopSpanRc = Abi.OK; - int newCollectionRc = Abi.OK; - long collectionId = 7; - int collectionMoreRc = Abi.OK; - boolean[] moreSequence = {false}; - private int moreIndex; - int collectionRejectRc = Abi.OK; - int targetRc = Abi.OK; - - // Failure list for the result. - static final class Failure { - String panic = "panic"; - String diagnostic = "diagnostic"; - String origin = "origin"; - } - - final List failures = new ArrayList<>(); - - @Override - public MemorySegment settingsNew() { - return SETTINGS; - } - - @Override - public void settingsFree(MemorySegment s) {} - - @Override - public void settingsMode(MemorySegment s, int mode) {} - - @Override - public void settingsTestCases(MemorySegment s, long n) {} - - @Override - public void settingsVerbosity(MemorySegment s, int v) {} - - @Override - public void settingsSeed(MemorySegment s, long seed, boolean hasSeed) {} - - @Override - public void settingsDerandomize(MemorySegment s, boolean derandomize) {} - - @Override - public void settingsReportMultipleFailures(MemorySegment s, boolean yes) {} - - @Override - public void settingsDatabase(MemorySegment s, String path) {} - - @Override - public void settingsDatabaseKey(MemorySegment s, String key) {} - - int phasesMask = -1; // captured; -1 means settingsPhases was never called - - @Override - public void settingsPhases(MemorySegment s, int mask) { - phasesMask = mask; - } - - @Override - public void settingsSuppressHealthCheck(MemorySegment s, int mask) {} - - @Override - public MemorySegment runStart(MemorySegment settings) { - return runStartNull ? MemorySegment.NULL : RUN; - } - - @Override - public MemorySegment nextTestCase(MemorySegment run) { - if (nextTestCaseError) { - return MemorySegment.NULL; - } - if (casesServed >= caseCount) { - lastError = doneLastError; - return MemorySegment.NULL; - } - casesServed++; - return TC; - } - - @Override - public MemorySegment runResult(MemorySegment run) { - return runResultNull ? MemorySegment.NULL : RESULT; - } - - @Override - public void runFree(MemorySegment run) {} - - @Override - public int generate(MemorySegment tc, byte[] schema, byte[][] out) { - if (generateRc == Abi.OK) { - out[0] = generateValue; - } - return generateRc; - } - - @Override - public int startSpan(MemorySegment tc, long label) { - return startSpanRc; - } - - @Override - public int stopSpan(MemorySegment tc, boolean discard) { - return stopSpanRc; - } - - @Override - public int newCollection(MemorySegment tc, long minSize, long maxSize, long[] outId) { - if (newCollectionRc == Abi.OK) { - outId[0] = collectionId; - } - return newCollectionRc; - } - - @Override - public int collectionMore(MemorySegment tc, long id, boolean[] outMore) { - if (collectionMoreRc == Abi.OK) { - outMore[0] = moreIndex < moreSequence.length && moreSequence[moreIndex++]; - } - return collectionMoreRc; - } - - @Override - public int collectionReject(MemorySegment tc, long id, String why) { - return collectionRejectRc; - } - - @Override - public int target(MemorySegment tc, double value, String label) { - return targetRc; - } - - @Override - public int markComplete(MemorySegment tc, int status, String origin) { - markedStatuses.add(status); - markedOrigins.add(origin); - return markCompleteRc; - } - - @Override - public boolean isFinalReplay(MemorySegment tc) { - return finalReplay; - } - - @Override - public boolean resultPassed(MemorySegment result) { - return passed; - } - - @Override - public long resultFailureCount(MemorySegment result) { - return failures.size(); - } - - @Override - public MemorySegment resultFailure(MemorySegment result, long index) { - return MemorySegment.ofAddress(0x500 + index); - } - - @Override - public String failurePanicMessage(MemorySegment failure) { - return failures.get((int) (failure.address() - 0x500)).panic; - } - - @Override - public String failureDiagnostic(MemorySegment failure) { - return failures.get((int) (failure.address() - 0x500)).diagnostic; - } - - @Override - public String failureOrigin(MemorySegment failure) { - return failures.get((int) (failure.address() - 0x500)).origin; - } - - @Override - public String lastErrorMessage() { - return lastError; - } - - @Override - public String version() { - return version; - } -} diff --git a/src/test/java/dev/hegel/FluentBuilderTest.java b/src/test/java/dev/hegel/FluentBuilderTest.java deleted file mode 100644 index 72a1f29..0000000 --- a/src/test/java/dev/hegel/FluentBuilderTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package dev.hegel; - -import static dev.hegel.Generators.binary; -import static dev.hegel.Generators.integers; -import static dev.hegel.Generators.lists; -import static dev.hegel.Generators.longs; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.upokecenter.cbor.CBORObject; -import org.junit.jupiter.api.Test; - -/** - * Bounds and sizes are configured exclusively through fluent builder methods. Each bound can be set - * independently, and an unset bound stays at its full-range / unbounded default. Equivalence is - * checked at the schema level (two builders that describe the same draw produce the same CBOR - * schema). - */ -class FluentBuilderTest { - private static CBORObject schema(Generator g) { - return g.asBasic().schema; - } - - @Test - void integersDefaultIsFullRange() { - assertEquals( - schema(integers()), schema(integers().min(Integer.MIN_VALUE).max(Integer.MAX_VALUE))); - } - - @Test - void integersIndependentBounds() { - // Setting one bound leaves the other at the full-range default. - assertEquals( - schema(integers().max(5)), - schema(integers().min(Integer.MIN_VALUE).max(5))); - assertEquals(schema(integers().min(-3)), schema(integers().min(-3).max(Integer.MAX_VALUE))); - } - - @Test - void longsIndependentBounds() { - assertEquals(schema(longs().max(5)), schema(longs().min(Long.MIN_VALUE).max(5))); - assertEquals(schema(longs().min(-3)), schema(longs().min(-3).max(Long.MAX_VALUE))); - } - - @Test - void binaryMinOnlyStaysUnbounded() { - // Setting only a minimum keeps the length unbounded above (no max_size in the schema). - CBORObject minOnly = schema(binary().minSize(2)); - assertEquals(2, minOnly.get("min_size").AsInt32()); - assertEquals(null, minOnly.get("max_size")); - } - - @Test - void listsMinOnlyStaysUnbounded() { - // Setting only a minimum leaves the length unbounded above (no max_size in the schema). - CBORObject minOnly = schema(lists(integers()).minSize(1)); - assertEquals(1, minOnly.get("min_size").AsInt32()); - assertEquals(null, minOnly.get("max_size")); - } -} diff --git a/src/test/java/dev/hegel/RealLibhegelTest.java b/src/test/java/dev/hegel/RealLibhegelTest.java deleted file mode 100644 index dbbc92a..0000000 --- a/src/test/java/dev/hegel/RealLibhegelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.hegel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.lang.foreign.Arena; -import java.lang.foreign.MemorySegment; -import java.lang.foreign.SymbolLookup; -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; - -/** Covers {@link RealLibhegel} edge branches that the normal engine path does not reach. */ -class RealLibhegelTest { - - static void throwsError() { - throw new AssertionError("boom"); // an Error (Throwable, not RuntimeException) - } - - static void throwsRuntime() { - throw new IllegalStateException("rt"); - } - - @Test - void invokeWrapsNonRuntimeThrowable() throws Exception { - MethodHandle h = MethodHandles.lookup() - .findStatic(RealLibhegelTest.class, "throwsError", MethodType.methodType(void.class)); - assertThrows(HegelException.class, () -> RealLibhegel.invoke(h)); - } - - @Test - void invokeRethrowsRuntimeException() throws Exception { - MethodHandle h = MethodHandles.lookup() - .findStatic(RealLibhegelTest.class, "throwsRuntime", MethodType.methodType(void.class)); - assertThrows(IllegalStateException.class, () -> RealLibhegel.invoke(h)); - } - - @Test - void readCStringHandlesNullAndValue() { - assertNull(RealLibhegel.readCString(null)); - assertNull(RealLibhegel.readCString(MemorySegment.NULL)); - try (Arena a = Arena.ofConfined()) { - assertEquals("hello", RealLibhegel.readCString(a.allocateFrom("hello"))); - } - } - - @Test - void findSymbolReturnsPresentAndThrowsOnMissing() { - Path lib = LibraryLoader.fromEnvironment().resolve(); - try (Arena a = Arena.ofShared()) { - SymbolLookup lookup = SymbolLookup.libraryLookup(lib, a); - assertNotNull(RealLibhegel.findSymbol(lookup, "hegel_version")); - assertThrows(HegelException.class, () -> RealLibhegel.findSymbol(lookup, "no_such_symbol_xyz")); - } - } - - @Test - void constructorRejectsBadPath() { - assertThrows(HegelException.class, () -> new RealLibhegel(Path.of("/nonexistent/libhegel.so"))); - } -} diff --git a/src/test/java/dev/hegel/RunnerTest.java b/src/test/java/dev/hegel/RunnerTest.java deleted file mode 100644 index 6f752af..0000000 --- a/src/test/java/dev/hegel/RunnerTest.java +++ /dev/null @@ -1,251 +0,0 @@ -package dev.hegel; - -import static dev.hegel.Generators.integers; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import org.junit.jupiter.api.Test; - -/** Covers {@link Runner} branches with a fake binding (no engine). */ -class RunnerTest { - private static final Map NO_CI = Map.of(); - private static final Map CI = Map.of("CI", "true"); - - private static PrintStream capture(ByteArrayOutputStream buf) { - return new PrintStream(buf, true, StandardCharsets.UTF_8); - } - - private static void run(FakeLibhegel fake, Settings s, Consumer body) { - Runner.run(fake, s, body, NO_CI, capture(new ByteArrayOutputStream())); - } - - @Test - void happyPathMarksValid() { - FakeLibhegel fake = new FakeLibhegel(); - fake.caseCount = 3; - run(fake, new Settings().database(Database.disabled()), tc -> tc.draw(integers())); - assertEquals(List.of(Abi.STATUS_VALID, Abi.STATUS_VALID, Abi.STATUS_VALID), fake.markedStatuses); - } - - @Test - void runStartNullThrows() { - FakeLibhegel fake = new FakeLibhegel(); - fake.runStartNull = true; - fake.lastError = "no start"; - HegelException e = assertThrows(HegelException.class, () -> run(fake, new Settings(), tc -> {})); - assertTrue(e.getMessage().contains("no start")); - } - - @Test - void nextTestCaseErrorThrows() { - FakeLibhegel fake = new FakeLibhegel(); - fake.nextTestCaseError = true; - fake.lastError = "explode"; - assertThrows(HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); - } - - @Test - void runResultNullThrows() { - FakeLibhegel fake = new FakeLibhegel(); - fake.caseCount = 0; - fake.runResultNull = true; - assertThrows(HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); - } - - @Test - void markCompleteErrorThrows() { - FakeLibhegel fake = new FakeLibhegel(); - fake.markCompleteRc = Abi.E_ALREADY_COMPLETE; - assertThrows(HegelException.class, () -> run(fake, new Settings().database(Database.disabled()), tc -> {})); - } - - @Test - void assumeMapsToInvalid() { - FakeLibhegel fake = new FakeLibhegel(); - run(fake, new Settings().database(Database.disabled()), tc -> tc.assume(false)); - assertEquals(List.of(Abi.STATUS_INVALID), fake.markedStatuses); - } - - @Test - void stopTestMapsToOverrun() { - FakeLibhegel fake = new FakeLibhegel(); - fake.generateRc = Abi.E_STOP_TEST; - run(fake, new Settings().database(Database.disabled()), tc -> tc.draw(integers())); - assertEquals(List.of(Abi.STATUS_OVERRUN), fake.markedStatuses); - } - - @Test - void assertionFailureMapsToInterestingAndRecordsOrigin() { - FakeLibhegel fake = new FakeLibhegel(); - fake.finalReplay = true; - run(fake, new Settings().database(Database.disabled()), tc -> { - throw new AssertionError("nope"); - }); - assertEquals(List.of(Abi.STATUS_INTERESTING), fake.markedStatuses); - assertTrue(fake.markedOrigins.get(0) != null); - } - - @Test - void hegelExceptionFromBodyPropagates() { - FakeLibhegel fake = new FakeLibhegel(); - fake.generateRc = Abi.E_BACKEND; - assertThrows( - HegelException.class, - () -> run(fake, new Settings().database(Database.disabled()), tc -> tc.draw(integers()))); - // The case was not marked complete; run_free drains it. - assertTrue(fake.markedStatuses.isEmpty()); - } - - @Test - void defaultModeRethrowsTheOriginalExceptionDirectly() { - // The default (report_multiple_failures off) surfaces the body's own exception instance — - // no "Hegel found ..." wrapper — so the stack trace and type are the user's. Covers both an - // Error (e.g. an assertion failure) and a RuntimeException. - AssertionError err = new AssertionError("boom-error"); - assertSame( - err, - assertThrows( - AssertionError.class, - () -> runFailing(tc -> { - throw err; - }))); - IllegalStateException rt = new IllegalStateException("boom-rt"); - assertSame( - rt, - assertThrows( - IllegalStateException.class, - () -> runFailing(tc -> { - throw rt; - }))); - } - - /** Drive a run that reports one failure (default mode), final-replaying {@code body}. */ - private static void runFailing(Consumer body) { - FakeLibhegel fake = new FakeLibhegel(); - fake.passed = false; - fake.finalReplay = true; - FakeLibhegel.Failure f = new FakeLibhegel.Failure(); - f.origin = Runner.originOf(new AssertionError()); - fake.failures.add(f); - run(fake, new Settings().database(Database.disabled()), body); - } - - @Test - void healthCheckFailureThrowsHealthCheckFailure() { - // A health check is reported as a failure whose panic message is "FailedHealthCheck: ..." and - // surfaces as HealthCheckFailure regardless of mode — not the body's exception, not a plain - // AssertionError. - FakeLibhegel fake = new FakeLibhegel(); - fake.passed = false; - FakeLibhegel.Failure f = new FakeLibhegel.Failure(); - f.panic = "FailedHealthCheck: FilterTooMuch — too many rejected"; - f.diagnostic = "FailedHealthCheck: FilterTooMuch — too many rejected\n"; - fake.failures.add(f); - HealthCheckFailure e = assertThrows( - HealthCheckFailure.class, - () -> run(fake, new Settings().database(Database.disabled()), tc -> tc.assume(false))); - assertTrue(e.getMessage().contains("FilterTooMuch"), e.getMessage()); - } - - @Test - void failureWithoutAFinalReplayFallsBackToEngineDiagnostic() { - // In default mode a failure the engine surfaces without a final replay (e.g. a health-check - // abort, or the replay phase disabled) has no Java exception to rethrow, so the report uses - // the engine's own diagnostic. - FakeLibhegel fake = new FakeLibhegel(); - fake.passed = false; // finalReplay defaults false: nothing captured - FakeLibhegel.Failure f = new FakeLibhegel.Failure(); - f.diagnostic = "engine diagnostic"; - f.origin = Runner.originOf(new AssertionError()); - fake.failures.add(f); - AssertionError e = assertThrows( - AssertionError.class, - () -> run(fake, new Settings().database(Database.disabled()), tc -> { - throw new AssertionError("search-only probe"); - })); - assertEquals("engine diagnostic", e.getMessage()); - } - - @Test - void reportMultipleFailuresStitchesEngineDiagnosticAndJavaMessage() { - FakeLibhegel fake = new FakeLibhegel(); - fake.passed = false; - fake.finalReplay = true; // the message is captured on the final replay - FakeLibhegel.Failure f = new FakeLibhegel.Failure(); - f.diagnostic = "the bug"; - // Align the engine-reported origin with what the runner computes so the captured - // message is stitched back in. - f.origin = Runner.originOf(new AssertionError("boom")); - fake.failures.add(f); - AssertionError e = assertThrows( - AssertionError.class, - () -> run(fake, new Settings().database(Database.disabled()).reportMultipleFailures(true), tc -> { - throw new AssertionError("boom"); - })); - assertTrue(e.getMessage().contains("1 failing example")); - assertTrue(e.getMessage().contains("the bug")); - assertTrue(e.getMessage().contains("boom"), e.getMessage()); - } - - @Test - void multipleFailuresUsePluralAndPanicFallback() { - FakeLibhegel fake = new FakeLibhegel(); - fake.passed = false; - FakeLibhegel.Failure f1 = new FakeLibhegel.Failure(); - f1.diagnostic = ""; - f1.panic = "panic-1"; - FakeLibhegel.Failure f2 = new FakeLibhegel.Failure(); - f2.diagnostic = "diag-2"; - fake.failures.add(f1); - fake.failures.add(f2); - AssertionError e = assertThrows( - AssertionError.class, - () -> run(fake, new Settings().database(Database.disabled()).reportMultipleFailures(true), tc -> {})); - assertTrue(e.getMessage().contains("2 distinct failing examples")); - assertTrue(e.getMessage().contains("panic-1")); - assertTrue(e.getMessage().contains("diag-2")); - } - - @Test - void settingsBranchesAllApplied() { - FakeLibhegel fake = new FakeLibhegel(); - Settings s = new Settings() - .testCases(10) - .seed(7) - .derandomize(true) - .reportMultipleFailures(false) - .mode(Mode.SINGLE_TEST_CASE) - .suppressHealthCheck(HealthCheck.FILTER_TOO_MUCH, HealthCheck.TOO_SLOW) - .phases(Phase.GENERATE, Phase.SHRINK) - .verbosity(Verbosity.VERBOSE) - .database(Database.path("/tmp/hegel-db")) - .name("myTest"); - run(fake, s, tc -> {}); - assertEquals(List.of(Abi.STATUS_VALID), fake.markedStatuses); - assertEquals(Phase.GENERATE.bit | Phase.SHRINK.bit, fake.phasesMask); - } - - @Test - void databaseDisabledAndCiDefaults() { - run(new FakeLibhegel(), new Settings().database(Database.disabled()), tc -> {}); - // CI default disables the database and derandomizes. - Runner.run(new FakeLibhegel(), new Settings(), tc -> {}, CI, capture(new ByteArrayOutputStream())); - // Non-CI default leaves the engine database enabled; a name derives a key. - Runner.run(new FakeLibhegel(), new Settings().name("t"), tc -> {}, NO_CI, capture(new ByteArrayOutputStream())); - } - - @Test - void originFallsBackToClassNameWithoutUserFrame() { - Throwable t = new RuntimeException("x"); - t.setStackTrace(new StackTraceElement[] {}); - assertEquals(RuntimeException.class.getName(), Runner.originOf(t)); - } -} diff --git a/src/test/java/dev/hegel/TestCaseTest.java b/src/test/java/dev/hegel/TestCaseTest.java deleted file mode 100644 index 63f5710..0000000 --- a/src/test/java/dev/hegel/TestCaseTest.java +++ /dev/null @@ -1,140 +0,0 @@ -package dev.hegel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.upokecenter.cbor.CBORObject; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class TestCaseTest { - /** A DataSource that returns a fixed value and records target calls. */ - private static final class StubSource implements DataSource { - double lastTarget = Double.NaN; - String lastLabel; - - @Override - public Object generate(CBORObject schema) { - return 7; - } - - @Override - public void startSpan(long label) {} - - @Override - public void stopSpan(boolean discard) {} - - @Override - public long newCollection(long minSize, long maxSize) { - return 0; - } - - @Override - public boolean collectionMore(long id) { - return false; - } - - @Override - public void collectionReject(long id, String why) {} - - @Override - public void target(double value, String label) { - lastTarget = value; - lastLabel = label; - } - } - - private TestCase newCase(StubSource s, boolean reporting, ByteArrayOutputStream buf) { - return new TestCase(s, reporting, new PrintStream(buf, true, StandardCharsets.UTF_8)); - } - - @Test - void drawReportsTopLevelWithLabelAndDefaultName() { - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - TestCase tc = newCase(new StubSource(), true, buf); - tc.draw(constant(1), "x"); - tc.draw(constant(2)); - String out = buf.toString(StandardCharsets.UTF_8); - assertTrue(out.contains("x = 1;"), out); - assertTrue(out.contains("draw_2 = 2;"), out); - } - - @Test - void drawDoesNotReportWhenNotReporting() { - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - TestCase tc = newCase(new StubSource(), false, buf); - assertEquals(5, tc.draw(constant(5))); - assertEquals("", buf.toString(StandardCharsets.UTF_8)); - } - - @Test - void nestedDrawsAreNotReported() { - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - TestCase tc = newCase(new StubSource(), true, buf); - Generator nested = new Generator<>() { - @Override - public Integer doDraw(TestCase inner) { - int a = inner.draw(constant(10)); // nested: should not be printed - return a + 1; - } - }; - tc.draw(nested, "top"); - String out = buf.toString(StandardCharsets.UTF_8); - assertTrue(out.contains("top = 11;"), out); - assertEquals(1, out.lines().count()); - } - - @Test - void noteRespectsReporting() { - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - TestCase reporting = newCase(new StubSource(), true, buf); - reporting.note("hello"); - assertTrue(buf.toString(StandardCharsets.UTF_8).contains("hello")); - - ByteArrayOutputStream quiet = new ByteArrayOutputStream(); - newCase(new StubSource(), false, quiet).note("nope"); - assertEquals("", quiet.toString(StandardCharsets.UTF_8)); - } - - @Test - void assumeAndTarget() { - StubSource s = new StubSource(); - TestCase tc = newCase(s, false, new ByteArrayOutputStream()); - tc.assume(true); - assertThrows(AssumeRejected.class, () -> tc.assume(false)); - tc.target(3.5); - assertEquals(3.5, s.lastTarget, 0.0); - assertEquals("", s.lastLabel); - tc.target(9.0, "score"); - assertEquals("score", s.lastLabel); - } - - @Test - void reprCoversAllShapes() { - assertEquals("null", TestCase.repr(null)); - assertEquals("\"a\\\\b\\\"c\"", TestCase.repr("a\\b\"c")); - assertEquals("[1, 2]", TestCase.repr(new byte[] {1, 2})); - assertEquals("[1, \"x\"]", TestCase.repr(List.of(1, "x"))); - assertEquals("[]", TestCase.repr(List.of())); - assertEquals("{1: 2}", TestCase.repr(Map.of(1, 2))); - java.util.LinkedHashMap m = new java.util.LinkedHashMap<>(); - m.put("a", 1); - m.put("b", 2); - assertEquals("{\"a\": 1, \"b\": 2}", TestCase.repr(m)); - assertEquals("42", TestCase.repr(42)); - } - - private static Generator constant(int v) { - return new Generator<>() { - @Override - public Integer doDraw(TestCase tc) { - return v; - } - }; - } -}