diff --git a/README.md b/README.md index 5442985..e62dce0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Add the dependency with Maven: dev.hegel hegel - 0.1.0 + 0.4.0 test ``` @@ -30,7 +30,7 @@ Add the dependency with Maven: or with Gradle: ```kotlin -testImplementation("dev.hegel:hegel:0.1.0") +testImplementation("dev.hegel:hegel:0.4.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). diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..ceb3374 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,22 @@ +RELEASE_TYPE: minor + +This release fixes several public-API defects found in an audit: + +- `Generators.durations()` now covers the full signed nanosecond range, so negative `Duration` + values are generated by default and `min()`/`max()` accept negative bounds. Use + `durations().min(Duration.ZERO)` for the previous non-negative behavior. +- `TextGenerator` rejects combining `categories(...)` with `excludeCategories(...)` instead of + silently ignoring the exclusion (the engine honors only one of the two constraints). +- The text, binary, list, set, and map generators reject every negative `maxSize(...)` argument. + Previously `maxSize(-1)` silently meant "unbounded" (it collided with an internal sentinel). +- The default `@HegelTest` property name — which derives the example-database key — is now the + fully-qualified method name (declaring class plus method name), so same-named test methods in + different classes no longer share replay-database entries. Stored entries under the old + bare-method-name keys will no longer be found (a one-time invalidation); explicit + `name = "..."` overrides are unaffected. +- Javadoc corrections: `characters()` generates one Unicode codepoint (possibly two UTF-16 + `char`s — use `codePointAt(0)`); `datetimes()` had its two zone methods swapped + (`timezones(...)` produces `ZonedDateTime`, `offsets(...)` produces `OffsetDateTime`); + `forType(Class)` documents the actual supported scalar set and that `List`/`Set`/`Optional`/ + `Map` are derived only as record components; `fromRegex(...)` documents the engine's + Python-`re` pattern dialect. diff --git a/src/main/java/dev/hegel/Generators.java b/src/main/java/dev/hegel/Generators.java index e5d4c13..59a07fb 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/src/main/java/dev/hegel/Generators.java @@ -113,9 +113,15 @@ public static TextGenerator text() { } /** - * Generates single-character strings. + * Generates strings of exactly one Unicode codepoint. A codepoint outside the Basic + * Multilingual Plane occupies two UTF-16 {@code char}s, so the generated string can have {@code + * String.length() == 2}; read the value with {@link String#codePointAt(int) codePointAt(0)} + * rather than {@code charAt(0)}. (Surrogates are excluded by default, as for {@link #text()}.) * - * @return a one-character text generator + *

The result is an ordinary {@link TextGenerator} with {@code minSize(1).maxSize(1)} applied, + * so calling further size methods (e.g. {@code minSize(0)}) replaces the one-codepoint contract. + * + * @return a one-codepoint text generator */ public static TextGenerator characters() { return text().minSize(1).maxSize(1); @@ -463,14 +469,21 @@ 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[]}, {@link UUID}, and their wrappers), enums, records - * (recursively), and {@code List}, {@code Set}, {@code Optional} and {@code Map} of supported - * element types. + *

Supports the scalar types {@code int}, {@code long}, {@code boolean}, {@code float} and + * {@code double} (and their wrappers), {@code String}, {@code byte[]}, {@link UUID}, {@link + * Duration}, {@link LocalDate}, {@link LocalTime}, {@link java.time.LocalDateTime}, {@link + * java.time.OffsetDateTime}, {@link java.time.ZonedDateTime}, {@link ZoneOffset} and {@link + * ZoneId}, plus enums and records (recursively). + * + *

{@code List}, {@code Set}, {@code Optional} and {@code Map} of supported element types are + * derived only as record components, where the declared element types are known. This {@code + * Class}-based entry point cannot express a parameterized type, so e.g. {@code + * forType(List.class)} throws. * * @param type the type to derive a generator for * @param the type * @return a generator producing instances of {@code type} + * @throws HegelException if no generator can be derived for {@code type} */ @SuppressWarnings("unchecked") public static Generator forType(Class type) { @@ -546,7 +559,8 @@ public static Generator times() { /** * 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. + * DST-aware {@link java.time.ZonedDateTime} values, or {@link DateTimeGenerator#offsets} to + * produce fixed-offset {@link java.time.OffsetDateTime} values instead. * * @return a datetime generator */ @@ -580,21 +594,26 @@ public static Generator zoneIds() { ZoneId.getAvailableZoneIds().stream().sorted().map(ZoneId::of).toList(); /** - * Generates {@link Duration} values across the representable nanosecond range. Configure with the - * fluent methods on {@link DurationGenerator}. + * Generates {@link Duration} values across the full signed nanosecond range — {@code + * [Long.MIN_VALUE, Long.MAX_VALUE]} nanoseconds, about ±292 years — including negative + * durations. Configure with the fluent methods on {@link DurationGenerator}. * * @return a duration generator */ public static DurationGenerator durations() { - return new DurationGenerator(0, Long.MAX_VALUE); + return new DurationGenerator(Long.MIN_VALUE, Long.MAX_VALUE); } /** * 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. + * to generate strings that merely contain a match, with arbitrary padding on either + * side (the unanchored semantics of Hypothesis's {@code from_regex}). + * + *

The pattern uses the engine's Python {@code re} dialect, which differs from {@link + * java.util.regex.Pattern} in some constructs. * - * @param pattern the regex pattern + * @param pattern the regex pattern (Python {@code re} dialect) * @return a regex generator */ public static RegexGenerator fromRegex(String pattern) { diff --git a/src/main/java/dev/hegel/HegelTest.java b/src/main/java/dev/hegel/HegelTest.java index dc56290..9344053 100644 --- a/src/main/java/dev/hegel/HegelTest.java +++ b/src/main/java/dev/hegel/HegelTest.java @@ -124,9 +124,11 @@ /** * Name for this property, used to derive a stable example-database key. Defaults to the test - * method name. + * method's fully-qualified name (declaring class name plus method name, e.g. {@code + * com.example.FooTest.myProperty}), so same-named methods in different classes get distinct + * database keys. * - * @return the property name, or {@code ""} to use the method name + * @return the property name, or {@code ""} to use the fully-qualified method name */ String name() default ""; } diff --git a/src/main/java/dev/hegel/HegelTestExtension.java b/src/main/java/dev/hegel/HegelTestExtension.java index 5a9bb5c..fc61498 100644 --- a/src/main/java/dev/hegel/HegelTestExtension.java +++ b/src/main/java/dev/hegel/HegelTestExtension.java @@ -74,14 +74,16 @@ public void interceptTestTemplateMethod( invocation.skip(); Method method = invocationContext.getExecutable(); HegelTest ann = method.getAnnotation(HegelTest.class); - Settings settings = settingsFrom(ann, method.getName()); + Settings settings = settingsFrom(ann, method); Object target = invocationContext.getTarget().orElse(null); Hegel.test(tc -> ReflectionSupport.invokeMethod(method, target, tc), settings); } } - static Settings settingsFrom(HegelTest ann, String methodName) { - String name = ann.name().isEmpty() ? methodName : ann.name(); + static Settings settingsFrom(HegelTest ann, Method method) { + // Qualify the default name with the declaring class: the name derives the example-database + // key, and a bare method name would collide across classes with same-named test methods. + String name = ann.name().isEmpty() ? method.getDeclaringClass().getName() + "." + method.getName() : ann.name(); Settings s = new Settings() .testCases(ann.testCases()) .verbosity(ann.verbosity()) diff --git a/src/main/java/dev/hegel/generators/BinaryGenerator.java b/src/main/java/dev/hegel/generators/BinaryGenerator.java index f2c35e0..9581d04 100644 --- a/src/main/java/dev/hegel/generators/BinaryGenerator.java +++ b/src/main/java/dev/hegel/generators/BinaryGenerator.java @@ -31,11 +31,11 @@ public BinaryGenerator minSize(int minSize) { } /** - * @param maxSize the maximum length (inclusive) + * @param maxSize the maximum length (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public BinaryGenerator maxSize(int maxSize) { - return new BinaryGenerator(minSize, maxSize); + return new BinaryGenerator(minSize, Sizes.checkedMax(maxSize, "binary")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/DurationGenerator.java b/src/main/java/dev/hegel/generators/DurationGenerator.java index 6d24e61..ae523f7 100644 --- a/src/main/java/dev/hegel/generators/DurationGenerator.java +++ b/src/main/java/dev/hegel/generators/DurationGenerator.java @@ -9,18 +9,16 @@ * Generates {@link Duration} values within an inclusive {@code [min, max]} range. Always basic (one * engine call). * - *

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 - * the fluent {@link #min(Duration)} / {@link #max(Duration)} methods. + *

Durations are drawn as a signed nanosecond count, so the representable range is {@code + * [Long.MIN_VALUE, Long.MAX_VALUE]} nanoseconds (about ±292 years), negative durations included. + * The default is that whole range; narrow it with the fluent {@link #min(Duration)} / {@link + * #max(Duration)} methods. */ public final class DurationGenerator implements Generator { private final long minNanos; private final long maxNanos; public DurationGenerator(long minNanos, long maxNanos) { - if (minNanos < 0) { - throw new IllegalArgumentException("durations: min must be non-negative"); - } if (minNanos > maxNanos) { throw new IllegalArgumentException("durations: min (" + minNanos + "ns) > max (" + maxNanos + "ns)"); } @@ -29,7 +27,8 @@ public DurationGenerator(long minNanos, long maxNanos) { } /** - * @param min the inclusive lower bound + * @param min the inclusive lower bound (may be negative; must fit in a {@code long} of + * nanoseconds) * @return a copy with the lower bound set */ public DurationGenerator min(Duration min) { @@ -37,7 +36,8 @@ public DurationGenerator min(Duration min) { } /** - * @param max the inclusive upper bound + * @param max the inclusive upper bound (may be negative; must fit in a {@code long} of + * nanoseconds) * @return a copy with the upper bound set */ public DurationGenerator max(Duration max) { diff --git a/src/main/java/dev/hegel/generators/ListGenerator.java b/src/main/java/dev/hegel/generators/ListGenerator.java index 9da3caf..3cadaa3 100644 --- a/src/main/java/dev/hegel/generators/ListGenerator.java +++ b/src/main/java/dev/hegel/generators/ListGenerator.java @@ -37,11 +37,11 @@ public ListGenerator minSize(int minSize) { } /** - * @param maxSize the maximum length (inclusive) + * @param maxSize the maximum length (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public ListGenerator maxSize(int maxSize) { - return new ListGenerator<>(element, minSize, maxSize); + return new ListGenerator<>(element, minSize, Sizes.checkedMax(maxSize, "lists")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/MapGenerator.java b/src/main/java/dev/hegel/generators/MapGenerator.java index 92e7c3b..bd679db 100644 --- a/src/main/java/dev/hegel/generators/MapGenerator.java +++ b/src/main/java/dev/hegel/generators/MapGenerator.java @@ -42,11 +42,11 @@ public MapGenerator minSize(int minSize) { } /** - * @param maxSize the maximum entry count (inclusive) + * @param maxSize the maximum entry count (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public MapGenerator maxSize(int maxSize) { - return new MapGenerator<>(keys, values, minSize, maxSize); + return new MapGenerator<>(keys, values, minSize, Sizes.checkedMax(maxSize, "maps")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/RegexGenerator.java b/src/main/java/dev/hegel/generators/RegexGenerator.java index 21eb868..5f8d0e2 100644 --- a/src/main/java/dev/hegel/generators/RegexGenerator.java +++ b/src/main/java/dev/hegel/generators/RegexGenerator.java @@ -8,6 +8,9 @@ * 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. Always basic (one engine call). + * + *

The pattern uses the engine's Python {@code re} dialect, which differs from {@link + * java.util.regex.Pattern} in some constructs. */ public final class RegexGenerator implements Generator { private final String pattern; diff --git a/src/main/java/dev/hegel/generators/SetGenerator.java b/src/main/java/dev/hegel/generators/SetGenerator.java index f936193..ad0b89a 100644 --- a/src/main/java/dev/hegel/generators/SetGenerator.java +++ b/src/main/java/dev/hegel/generators/SetGenerator.java @@ -37,11 +37,11 @@ public SetGenerator minSize(int minSize) { } /** - * @param maxSize the maximum size (inclusive) + * @param maxSize the maximum size (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public SetGenerator maxSize(int maxSize) { - return new SetGenerator<>(element, minSize, maxSize); + return new SetGenerator<>(element, minSize, Sizes.checkedMax(maxSize, "sets")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/Sizes.java b/src/main/java/dev/hegel/generators/Sizes.java index 3246a72..98f24d8 100644 --- a/src/main/java/dev/hegel/generators/Sizes.java +++ b/src/main/java/dev/hegel/generators/Sizes.java @@ -6,6 +6,19 @@ final class Sizes { private Sizes() {} + /** + * Validate a user-supplied maximum size from a public fluent setter. Rejects every negative + * value so the engine's internal "unbounded" sentinel ({@code -1}) can never be smuggled in + * through the public API. + */ + static long checkedMax(long maxSize, String what) { + if (maxSize < 0) { + throw new IllegalArgumentException( + what + ": maxSize must be >= 0, got " + maxSize + "; omit the maxSize call for no upper bound"); + } + return maxSize; + } + static void validate(long minSize, long maxSize, String what) { if (minSize < 0) { throw new IllegalArgumentException(what + ": minSize must be >= 0, got " + minSize); diff --git a/src/main/java/dev/hegel/generators/TextGenerator.java b/src/main/java/dev/hegel/generators/TextGenerator.java index 6c0fcaa..ef5c2cc 100644 --- a/src/main/java/dev/hegel/generators/TextGenerator.java +++ b/src/main/java/dev/hegel/generators/TextGenerator.java @@ -34,6 +34,10 @@ public TextGenerator( String includeChars, String excludeChars) { Sizes.validate(minSize, maxSize, "text"); + if (categories != null && excludeCategories != null) { + throw new IllegalArgumentException("text: categories() and excludeCategories() cannot be combined;" + + " express the constraint one way, e.g. list only the allowed categories"); + } this.minSize = minSize; this.maxSize = maxSize; this.minCodepoint = minCodepoint; @@ -61,13 +65,13 @@ public TextGenerator minSize(int minSize) { } /** - * @param maxSize the maximum codepoint length + * @param maxSize the maximum codepoint length (must be {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public TextGenerator maxSize(int maxSize) { return new TextGenerator( minSize, - maxSize, + Sizes.checkedMax(maxSize, "text"), minCodepoint, maxCodepoint, categories, @@ -93,8 +97,12 @@ public TextGenerator codepoints(int min, int max) { /** * Restrict to the listed Unicode general categories (e.g. {@code "Lu"}, {@code "Nd"}). * + *

Cannot be combined with {@link #excludeCategories}: the engine honors only one of the two + * constraints, so express the restriction one way. + * * @param cats the allowed categories * @return a copy with the categories set + * @throws IllegalArgumentException if {@link #excludeCategories} was already set */ public TextGenerator categories(String... cats) { return new TextGenerator( @@ -109,8 +117,14 @@ public TextGenerator categories(String... cats) { } /** + * Exclude the listed Unicode general categories. + * + *

Cannot be combined with {@link #categories}: the engine honors only one of the two + * constraints, so express the restriction one way. + * * @param cats categories to exclude * @return a copy with excluded categories set + * @throws IllegalArgumentException if {@link #categories} was already set */ public TextGenerator excludeCategories(String... cats) { return new TextGenerator( diff --git a/src/test/java/dev/hegel/ConformanceTest.java b/src/test/java/dev/hegel/ConformanceTest.java index 556e0f1..67f8a61 100644 --- a/src/test/java/dev/hegel/ConformanceTest.java +++ b/src/test/java/dev/hegel/ConformanceTest.java @@ -97,6 +97,17 @@ void textRespectsLengthAndCharacters() { assertAllExamples(characters(), s -> cp(s) == 1); } + @Test + void charactersAreSingleCodepointsNotSingleChars() { + // characters() means one Unicode CODEPOINT. A supplementary-plane codepoint occupies two + // UTF-16 chars, so String.length() == 2 there; codePointAt(0) is the safe accessor. + assertAllExamples( + characters().codepoints(0x10000, 0x10FFF), + s -> s.codePointCount(0, s.length()) == 1 + && s.length() == 2 + && Character.isSupplementaryCodePoint(s.codePointAt(0))); + } + @Test void binaryRespectsLength() { assertAllExamples(binary().minSize(1).maxSize(4), b -> b.length >= 1 && b.length <= 4); @@ -216,10 +227,16 @@ void temporalGeneratorsProduceJavaTimeTypes() { assertAllExamples(dates(), d -> d.getYear() >= 1 && d.getYear() <= 9999); assertAllExamples(times(), t -> t.getHour() >= 0 && t.getHour() <= 23); assertAllExamples(datetimes(), dt -> dt.getDayOfMonth() >= 1 && dt.getDayOfMonth() <= 31); - assertAllExamples(durations(), d -> !d.isNegative() && d.toNanos() <= Long.MAX_VALUE); + // durations() spans the full signed nanosecond range: negative values are reachable by + // default (Duration is signed, like Hypothesis's timedeltas)... + assertTrue(findAny(durations(), Duration::isNegative).isNegative()); + // ...and bounds are honored, on either side of zero. assertAllExamples( durations().min(Duration.ofSeconds(1)).max(Duration.ofSeconds(60)), d -> d.compareTo(Duration.ofSeconds(1)) >= 0 && d.compareTo(Duration.ofSeconds(60)) <= 0); + assertAllExamples( + durations().min(Duration.ofSeconds(-60)).max(Duration.ofSeconds(-1)), + d -> d.compareTo(Duration.ofSeconds(-60)) >= 0 && d.compareTo(Duration.ofSeconds(-1)) <= 0); } @Test diff --git a/src/test/java/dev/hegel/CoverageTest.java b/src/test/java/dev/hegel/CoverageTest.java index 759eb4e..ca47583 100644 --- a/src/test/java/dev/hegel/CoverageTest.java +++ b/src/test/java/dev/hegel/CoverageTest.java @@ -8,6 +8,7 @@ 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.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -133,13 +134,13 @@ void hegelTestHelpers() throws Exception { assertTrue(HegelTestExtension.isTestCaseParam(TestCase.class)); assertFalse(HegelTestExtension.isTestCaseParam(String.class)); - Settings withSeed = HegelTestExtension.settingsFrom(seeded.getAnnotation(HegelTest.class), "s"); + Settings withSeed = HegelTestExtension.settingsFrom(seeded.getAnnotation(HegelTest.class), seeded); 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"); + // Defaults: no seed, no derandomize override, engine-default phases, default database, and + // the fully-qualified method name as the property name. + Settings noSeed = HegelTestExtension.settingsFrom(unseeded.getAnnotation(HegelTest.class), unseeded); assertFalse(noSeed.hasSeed); assertNull(noSeed.derandomize); assertNull(noSeed.phasesMask); @@ -147,12 +148,12 @@ void hegelTestHelpers() throws Exception { assertEquals(0, noSeed.suppressMask); assertEquals(Mode.TEST_RUN, noSeed.mode); assertFalse(noSeed.reportMultipleFailures); - assertEquals("u", noSeed.name); + assertEquals("dev.hegel.CoverageTest$Holder.unseeded", 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"); + Settings c = HegelTestExtension.settingsFrom(configured.getAnnotation(HegelTest.class), configured); assertEquals(Boolean.TRUE, c.derandomize); assertEquals(Integer.valueOf(Phase.GENERATE.bit), c.phasesMask); assertEquals(HealthCheck.TOO_SLOW.bit, c.suppressMask); @@ -163,18 +164,41 @@ void hegelTestHelpers() throws Exception { // 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"); + Settings e = HegelTestExtension.settingsFrom(emptyPhases.getAnnotation(HegelTest.class), emptyPhases); 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"); + Settings d = HegelTestExtension.settingsFrom(customDb.getAnnotation(HegelTest.class), customDb); assertEquals(Database.Kind.PATH, d.database.kind); assertEquals("/tmp/hdb", d.database.path); } + // Two classes with identically-named test methods: their default property names (and hence + // example-database keys, which the engine derives from the name) must not collide. + static final class SameNameA { + @HegelTest + void prop(TestCase tc) {} + } + + static final class SameNameB { + @HegelTest + void prop(TestCase tc) {} + } + + @Test + void sameNamedMethodsInDifferentClassesGetDistinctDatabaseKeys() throws Exception { + Method a = SameNameA.class.getDeclaredMethod("prop", TestCase.class); + Method b = SameNameB.class.getDeclaredMethod("prop", TestCase.class); + Settings sa = HegelTestExtension.settingsFrom(a.getAnnotation(HegelTest.class), a); + Settings sb = HegelTestExtension.settingsFrom(b.getAnnotation(HegelTest.class), b); + assertNotEquals(sa.name, sb.name); + assertEquals("dev.hegel.CoverageTest$SameNameA.prop", sa.name); + assertEquals("dev.hegel.CoverageTest$SameNameB.prop", sb.name); + } + // --- Runner residual branches via fake --- private static void run(FakeLibhegel fake, java.util.function.Consumer body) { Runner.run( diff --git a/src/test/java/dev/hegel/DerivationTest.java b/src/test/java/dev/hegel/DerivationTest.java index cf1e3ca..b3bc66f 100644 --- a/src/test/java/dev/hegel/DerivationTest.java +++ b/src/test/java/dev/hegel/DerivationTest.java @@ -65,6 +65,9 @@ void perComponentOverride() { @Test void unsupportedTypeFailsClearly() { assertThrows(HegelException.class, () -> forType(Object.class)); + // Collections are supported only as record components (where the declared element type is + // known); the Class-based entry point cannot express them. + assertThrows(HegelException.class, () -> forType(List.class)); // Unsupported generic element type surfaces when the record is generated. assertThrows( HegelException.class, diff --git a/src/test/java/dev/hegel/GeneratorSmokeTest.java b/src/test/java/dev/hegel/GeneratorSmokeTest.java index aeae7b2..7c9ba1d 100644 --- a/src/test/java/dev/hegel/GeneratorSmokeTest.java +++ b/src/test/java/dev/hegel/GeneratorSmokeTest.java @@ -124,8 +124,9 @@ void formatGenerators(TestCase tc) { assertTrue(tc.draw(dates()).getYear() >= 1); assertTrue(tc.draw(times()).getHour() >= 0); assertTrue(tc.draw(datetimes()).getYear() >= 1); - assertTrue(!tc.draw(durations()).isNegative()); - assertTrue(tc.draw(fromRegex("[a-z]{3}")).length() >= 0); + assertTrue(!tc.draw(durations().min(java.time.Duration.ZERO)).isNegative()); + // Fullmatch semantics by default: the whole drawn string matches the pattern. + assertTrue(tc.draw(fromRegex("[a-z]{3}")).matches("[a-z]{3}")); } @HegelTest diff --git a/src/test/java/dev/hegel/GeneratorValidationTest.java b/src/test/java/dev/hegel/GeneratorValidationTest.java index 4d55efa..58bb6b5 100644 --- a/src/test/java/dev/hegel/GeneratorValidationTest.java +++ b/src/test/java/dev/hegel/GeneratorValidationTest.java @@ -64,12 +64,25 @@ void textConstraints() { IllegalArgumentException.class, () -> text().categories("C").asBasic()); } + @Test + void textCategoriesAndExcludeCategoriesConflict() { + // The engine schema honors only one of categories/exclude_categories, so combining them + // must fail loudly instead of silently ignoring the exclusion (in either call order). + assertThrows( + IllegalArgumentException.class, () -> text().categories("L").excludeCategories("Lu")); + assertThrows( + IllegalArgumentException.class, + () -> text().excludeCategories("Lu").categories("L")); + } + @Test void durationBounds() { assertThrows( IllegalArgumentException.class, () -> durations().min(java.time.Duration.ofSeconds(2)).max(java.time.Duration.ofSeconds(1))); - assertThrows(IllegalArgumentException.class, () -> durations().min(java.time.Duration.ofSeconds(-1))); + // Duration is signed, so negative bounds are legal. + durations().min(java.time.Duration.ofSeconds(-1)); + durations().min(java.time.Duration.ofSeconds(-2)).max(java.time.Duration.ofSeconds(-1)); } @Test @@ -93,6 +106,20 @@ void collectionBounds() { () -> maps(integers(), integers()).minSize(5).maxSize(3)); } + @Test + void negativeMaxSizeRejected() { + // -1 is the engine's internal "unbounded" sentinel; passed to the public setters it used + // to silently lift the bound instead of failing. All negative values must be rejected. + assertThrows(IllegalArgumentException.class, () -> text().maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> binary().maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> lists(integers()).maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> sets(integers()).maxSize(-1)); + assertThrows( + IllegalArgumentException.class, + () -> maps(integers(), integers()).maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> text().maxSize(-2)); + } + @Test void selectionEmptiness() { assertThrows(IllegalArgumentException.class, () -> sampledFrom(List.of()));