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:
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 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 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 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 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