From 8187f024bae57267af514c5dcb730de09e573e41 Mon Sep 17 00:00:00 2001 From: Carlo Zancanaro Date: Mon, 1 Jun 2015 11:41:16 +1000 Subject: Move packages, make lists shrink in size, generate lists instead of arrays as the 'primitive' operation (issues with generics) --- src/main/java/au/id/zancanaro/Generator.java | 35 --- src/main/java/au/id/zancanaro/Generators.java | 80 ------ src/main/java/au/id/zancanaro/Iterators.java | 175 ------------- src/main/java/au/id/zancanaro/Properties.java | 279 -------------------- src/main/java/au/id/zancanaro/PropertyError.java | 49 ---- src/main/java/au/id/zancanaro/RoseTree.java | 103 -------- src/main/java/au/id/zancanaro/ShrinkResult.java | 11 - .../au/id/zancanaro/annotations/DataSource.java | 11 - .../java/au/id/zancanaro/annotations/Property.java | 13 - .../java/au/id/zancanaro/annotations/Seed.java | 12 - .../java/au/id/zancanaro/javacheck/Generator.java | 36 +++ .../java/au/id/zancanaro/javacheck/Generators.java | 135 ++++++++++ .../java/au/id/zancanaro/javacheck/Iterators.java | 177 +++++++++++++ .../java/au/id/zancanaro/javacheck/RoseTree.java | 105 ++++++++ .../au/id/zancanaro/javacheck/ShrinkResult.java | 11 + .../javacheck/annotations/DataSource.java | 11 + .../zancanaro/javacheck/annotations/Property.java | 13 + .../id/zancanaro/javacheck/annotations/Seed.java | 12 + .../id/zancanaro/javacheck/junit/Properties.java | 282 +++++++++++++++++++++ .../zancanaro/javacheck/junit/PropertyError.java | 51 ++++ 20 files changed, 833 insertions(+), 768 deletions(-) delete mode 100644 src/main/java/au/id/zancanaro/Generator.java delete mode 100644 src/main/java/au/id/zancanaro/Generators.java delete mode 100644 src/main/java/au/id/zancanaro/Iterators.java delete mode 100644 src/main/java/au/id/zancanaro/Properties.java delete mode 100644 src/main/java/au/id/zancanaro/PropertyError.java delete mode 100644 src/main/java/au/id/zancanaro/RoseTree.java delete mode 100644 src/main/java/au/id/zancanaro/ShrinkResult.java delete mode 100644 src/main/java/au/id/zancanaro/annotations/DataSource.java delete mode 100644 src/main/java/au/id/zancanaro/annotations/Property.java delete mode 100644 src/main/java/au/id/zancanaro/annotations/Seed.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/Generator.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/Generators.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/Iterators.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/RoseTree.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/ShrinkResult.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/annotations/DataSource.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/annotations/Property.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/annotations/Seed.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/junit/Properties.java create mode 100644 src/main/java/au/id/zancanaro/javacheck/junit/PropertyError.java (limited to 'src/main/java') diff --git a/src/main/java/au/id/zancanaro/Generator.java b/src/main/java/au/id/zancanaro/Generator.java deleted file mode 100644 index 1c948af..0000000 --- a/src/main/java/au/id/zancanaro/Generator.java +++ /dev/null @@ -1,35 +0,0 @@ -package au.id.zancanaro; - -import java.util.Random; -import java.util.function.Function; - -public interface Generator { - RoseTree generate(Random random, int size); - - static Generator pure(T value) { - return (random, size) -> RoseTree.pure(value); - } - - @SafeVarargs - static Generator tuple(Generator... generators) { - return (random, size) -> { - @SuppressWarnings("unchecked") - RoseTree[] result = (RoseTree[]) new RoseTree[generators.length]; - int index = 0; - for (Generator generator : generators) { - result[index++] = generator.generate(random, size); - } - return RoseTree.zip(Function.identity(), result); - }; - } - - default Generator fmap(Function f) { - return (random, size) -> this.generate(random, size).fmap(f); - } - - default Generator flatMap(Function> action) { - return (random, size) -> { - return RoseTree.join(this.generate(random, size).fmap(action).fmap(g -> g.generate(random, size))); - }; - } -} diff --git a/src/main/java/au/id/zancanaro/Generators.java b/src/main/java/au/id/zancanaro/Generators.java deleted file mode 100644 index 01ab64a..0000000 --- a/src/main/java/au/id/zancanaro/Generators.java +++ /dev/null @@ -1,80 +0,0 @@ -package au.id.zancanaro; - -import java.util.Arrays; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.function.Function; -import java.util.function.Predicate; - -public class Generators { - public static Generator sized(Function> makeGenerator) { - return (random, size) -> makeGenerator.apply(size).generate(random, size); - } - - public static Generator suchThat(Generator gen, Predicate pred) { - return (random, size) -> { - RoseTree result = gen.generate(random, size); - if (pred.test(result.getValue())) { - return result.filter(pred); - } else { - return suchThat(gen, pred).generate(random, size); - } - }; - } - - @SafeVarargs - public static Generator oneOf(Generator... gens) { - return integer(0, gens.length).flatMap(index -> gens[index]); - } - - public static Generator bool() { - return (random, size) -> - random.nextBoolean() ? - new RoseTree<>(true, Collections.singletonList(new RoseTree<>(false, Collections.emptyList()))) : - new RoseTree<>(false, Collections.emptyList()); - } - - public static Generator integer(int lower, int upper) { - return (random, size) -> { - int value = lower + random.nextInt(upper - lower); - int bound = lower > 0 ? lower : (upper < 0 ? upper : 0); - return new RoseTree<>(value, intShrinkingIterable(value, bound)); - }; - } - - public static Generator integer() { - return sized(size -> integer(-size, size)); - } - - public static Generator natural() { - return sized(size -> integer(0, size)); - } - - private static Iterable> intShrinkingIterable(final int value, final int bound) { - return () -> new Iterator>() { - int curr = value - bound; - - @Override - public boolean hasNext() { - return curr != 0; - } - - @Override - public RoseTree next() { - int prevCurr = curr; - curr = curr / 2; - return new RoseTree<>(value - prevCurr, intShrinkingIterable(value - prevCurr, bound)); - } - }; - } - - public static Generator> listOf(Generator gen) { - return (Generator>) sized(size -> { - @SuppressWarnings("unchecked") - Generator[] gens = (Generator[]) new Generator[size]; - Arrays.fill(gens, gen); - return Generator.tuple(gens).fmap(Arrays::asList).fmap(Collections::unmodifiableList); - }); - } -} diff --git a/src/main/java/au/id/zancanaro/Iterators.java b/src/main/java/au/id/zancanaro/Iterators.java deleted file mode 100644 index 08cc37e..0000000 --- a/src/main/java/au/id/zancanaro/Iterators.java +++ /dev/null @@ -1,175 +0,0 @@ -package au.id.zancanaro; - -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Predicate; - -public final class Iterators { - public static RangeIterator rangeIterator(int countTo, Function fn) { - return new RangeIterator(countTo, fn); - } - - private static class RangeIterator implements Iterator { - private final Function action; - private final int countTo; - private int index = 0; - - public RangeIterator(int countTo, Function action) { - this.countTo = countTo; - this.action = action; - } - - @Override - public boolean hasNext() { - return index < countTo; - } - - @Override - public T next() { - return action.apply(index++); - } - } - - public static FlattenIterator flatten(Iterator> iterators) { - return new FlattenIterator<>(iterators); - } - public static class FlattenIterator implements Iterator { - private Iterator current; - - private Iterator> iterators; - - public FlattenIterator(Iterator> iterators) { - this.current = Iterators.emptyIterator(); - this.iterators = iterators; - } - - private Iterator getCurrent() { - while (!current.hasNext() && iterators.hasNext()) { - current = iterators.next(); - } - return current; - } - - @Override - public boolean hasNext() { - return getCurrent().hasNext(); - } - @Override - public T next() { - return getCurrent().next(); - } - - } - - public static ConcatIterator concat(Iterator left, Iterator right) { - return new ConcatIterator<>(left, right); - } - public static class ConcatIterator implements Iterator { - private final Iterator left; - - private final Iterator right; - - public ConcatIterator(Iterator left, Iterator right) { - this.left = left; - this.right = right; - } - - @Override - public boolean hasNext() { - return left.hasNext() || right.hasNext(); - } - @Override - public T next() { - if (left.hasNext()) { - return left.next(); - } else { - return right.next(); - } - } - - } - - public static EmptyIterator emptyIterator() { - return new EmptyIterator<>(); - } - public static class EmptyIterator implements Iterator { - - @Override - public boolean hasNext() { - return false; - } - @Override - public T next() { - return null; - } - - } - - public static MappingIterator mappingIterator(Function f, Iterator iterator) { - return new MappingIterator<>(f, iterator); - } - private static class MappingIterator implements Iterator { - private final Function mapping; - - private final Iterator iterator; - - public MappingIterator(Function mapping, Iterator iterator) { - this.mapping = mapping; - this.iterator = iterator; - } - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - @Override - public R next() { - return mapping.apply(iterator.next()); - } - - } - - public static FilteringIterator filteringIterator(Predicate pred, Iterator iterator) { - return new FilteringIterator<>(pred, iterator); - } - - private static class FilteringIterator implements Iterator { - private final Predicate predicate; - private final Iterator iterator; - private List nextValue; - - public FilteringIterator(Predicate pred, Iterator iterator) { - this.predicate = pred; - this.iterator = iterator; - this.nextValue = null; - } - - private void populateNext() { - while (nextValue == null && iterator.hasNext()) { - T value = iterator.next(); - if (predicate.test(value)) { - nextValue = Collections.singletonList(value); - } else { - nextValue = null; - } - } - } - - @Override - public boolean hasNext() { - populateNext(); - return nextValue != null; - } - - @Override - public T next() { - populateNext(); - T result = nextValue.get(0); - nextValue = null; - return result; - } - } -} diff --git a/src/main/java/au/id/zancanaro/Properties.java b/src/main/java/au/id/zancanaro/Properties.java deleted file mode 100644 index b5a0649..0000000 --- a/src/main/java/au/id/zancanaro/Properties.java +++ /dev/null @@ -1,279 +0,0 @@ -package au.id.zancanaro; - -import au.id.zancanaro.annotations.DataSource; -import au.id.zancanaro.annotations.Property; -import au.id.zancanaro.annotations.Seed; -import org.junit.AssumptionViolatedException; -import org.junit.runners.BlockJUnit4ClassRunner; -import org.junit.runners.model.FrameworkMethod; -import org.junit.runners.model.InitializationError; -import org.junit.runners.model.Statement; -import org.junit.runners.model.TestClass; - -import java.lang.reflect.*; -import java.util.*; - - -public class Properties extends BlockJUnit4ClassRunner { - private final Map> generators = new HashMap<>(); - - public Properties(Class klass) throws InitializationError { - super(klass); - } - - @Override - protected void collectInitializationErrors(List errors) { - super.collectInitializationErrors(errors); - Set generated = validateGeneratorFields(errors); - validateTestMethodParameters(errors, generated); - } - - private Set validateGeneratorFields(List errors) { - Set result = new HashSet<>(); - Field[] fields = getTestClass().getJavaClass().getDeclaredFields(); - - for (Field field : fields) { - if (!field.isAnnotationPresent(DataSource.class)) { - continue; - } - Type type = field.getGenericType(); - if (!(type instanceof ParameterizedType)) { - continue; - } - ParameterizedType ptype = (ParameterizedType) type; - if (!(ptype.getRawType() instanceof Class)) { - continue; - } - Class c = (Class) ptype.getRawType(); - if (c != Generator.class) { - continue; - } - boolean error = false; - if (!Modifier.isStatic(field.getModifiers())) { - errors.add(new Error("Generator field " + field.getName() + " must be static")); - error = true; - } - if (!Modifier.isPublic(field.getModifiers())) { - errors.add(new Error("Generator field " + field.getName() + " must be public")); - error = true; - } - if (!error) { - result.add(ptype.getActualTypeArguments()[0]); - } - } - return result; - } - - private void validateTestMethodParameters(List errors, Set generated) { - for (FrameworkMethod each : computeTestMethods()) { - for (Type type : each.getMethod().getGenericParameterTypes()) { - if (!generated.contains(type)) { - errors.add(new Error("No @DataSource for type: " + type)); - generated.add(type); // ignore future errors on this type - } - } - } - } - - private static final Map rawTypes; - - static { - Map types = new HashMap<>(); - types.put(Double.class, Double.TYPE); - types.put(Float.class, Float.TYPE); - types.put(Long.class, Long.TYPE); - types.put(Integer.class, Integer.TYPE); - types.put(Short.class, Short.TYPE); - types.put(Byte.class, Byte.TYPE); - types.put(Character.class, Character.TYPE); - types.put(Boolean.class, Boolean.TYPE); - rawTypes = Collections.unmodifiableMap(types); - } - - private Map> computeGenerators() { - if (generators.isEmpty()) { - Field[] fields = getTestClass().getJavaClass().getDeclaredFields(); - - for (Field field : fields) { - if (!field.isAnnotationPresent(DataSource.class)) { - continue; - } - Type type = field.getGenericType(); - if (!(type instanceof ParameterizedType)) { - continue; - } - ParameterizedType ptype = (ParameterizedType) type; - if (!(ptype.getRawType() instanceof Class)) { - continue; - } - Class c = (Class) ptype.getRawType(); - if (c != Generator.class) { - continue; - } - try { - Type target = ptype.getActualTypeArguments()[0]; - @SuppressWarnings("unchecked") - Generator generator = (Generator) field.get(null); - generators.put(target, generator); - if (rawTypes.containsKey(target)) { - generators.put(rawTypes.get(target), generator); - } - } catch (IllegalAccessException ex) { - - } - } - } - return generators; - } - - @Override - protected void validateConstructor(List errors) { - validateOnlyOneConstructor(errors); - } - - @Override - protected void validateTestMethods(List errors) { - for (FrameworkMethod each : computeTestMethods()) { - if (each.getAnnotation(Property.class) != null) { - each.validatePublicVoid(false, errors); - each.validateNoTypeParametersOnArgs(errors); - } else { - each.validatePublicVoidNoArg(false, errors); - } - } - } - - @Override - protected List computeTestMethods() { - List testMethods = new ArrayList<>(super.computeTestMethods()); - List theoryMethods = getTestClass().getAnnotatedMethods(Property.class); - testMethods.removeAll(theoryMethods); - testMethods.addAll(theoryMethods); - return testMethods; - } - - @Override - public Statement methodBlock(final FrameworkMethod method) { - return new GenerativeTester(method, getTestClass(), computeGenerators()); - } - - public static class GenerativeTester extends Statement { - private final FrameworkMethod testMethod; - private final TestClass testClass; - private final Map> generators; - - public GenerativeTester(FrameworkMethod testMethod, TestClass testClass, Map> generators) { - this.testMethod = testMethod; - this.testClass = testClass; - this.generators = generators; - } - - private long getSeed(Method method) { - Seed seed = method.getAnnotation(Seed.class); - if (seed == null) { - return System.currentTimeMillis(); - } else { - return seed.value(); - } - } - - @Override - public void evaluate() throws Throwable { - Method method = testMethod.getMethod(); - if (method.getParameterCount() == 0) { - runTest(new Object[0]); - } else { - @SuppressWarnings("unchecked") - Generator[] generators = (Generator[]) new Generator[method.getParameterCount()]; - int index = 0; - for (Type type : method.getGenericParameterTypes()) { - // TODO: validate ahead of time that this generator will exist (ideally in the constructor validation time) - generators[index++] = this.generators.get(type); - } - @SuppressWarnings("unchecked") - Generator generator = Generator.tuple((Generator[]) generators); - - long seed = getSeed(method); - Random random = new Random(seed); - - Property property = testMethod.getAnnotation(Property.class); - int assumptionsViolated = 0; - int maxSize = property.maxSize(); - int numTests = property.runs(); - for (int i = 0; i < numTests; ++i) { - int size = Math.min(i + 1, maxSize); - RoseTree tree = generator.generate(random, size); - try { - runTest(tree.getValue()); - assumptionsViolated = 0; - } catch (AssumptionViolatedException ex) { - numTests++; - if (assumptionsViolated++ == 50) { - throw new Error("Violated 50 assumptions in a row: failing test"); - } - ; - } catch (Throwable ex) { -// tree.print(new OutputStreamWriter(System.out), Arrays::toString); - throw new PropertyError(method.getName(), seed, shrink(tree, ex)); - } - } - } - } - - private ShrinkResult shrink(RoseTree failed, Throwable originalEx) { - ShrinkResult smallest = new ShrinkResult(failed.getValue(), originalEx); - Iterator> trees = failed.getChildren(); - Set> seenArgs = new HashSet<>(); - while (trees.hasNext()) { - RoseTree tree = trees.next(); - if (seenArgs.add(Arrays.asList(tree.getValue()))) { - try { - runTest(tree.getValue()); - } catch (AssumptionViolatedException ex) { - // ignore, because it's not useful - } catch (Throwable ex) { - smallest = new ShrinkResult(tree.getValue(), ex); - Iterator> children = tree.getChildren(); - if (children.hasNext()) { - trees = children; - } else { - break; - } - } - } - } - return smallest; - } - - public void runTest(final Object[] args) throws Throwable { - new BlockJUnit4ClassRunner(testClass.getJavaClass()) { - @Override - protected void collectInitializationErrors( - List errors) { - // do nothing - } - - @Override - public Statement methodBlock(FrameworkMethod method) { - return super.methodBlock(method); - } - - @Override - protected Statement methodInvoker(FrameworkMethod method, Object test) { - return new Statement() { - @Override - public void evaluate() throws Throwable { - method.invokeExplosively(test, args); - } - }; - } - - @Override - public Object createTest() throws Exception { - return getTestClass().getOnlyConstructor().newInstance(); - } - }.methodBlock(testMethod).evaluate(); - } - } - -} \ No newline at end of file diff --git a/src/main/java/au/id/zancanaro/PropertyError.java b/src/main/java/au/id/zancanaro/PropertyError.java deleted file mode 100644 index a0ee6df..0000000 --- a/src/main/java/au/id/zancanaro/PropertyError.java +++ /dev/null @@ -1,49 +0,0 @@ -package au.id.zancanaro; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Iterator; - -public class PropertyError extends AssertionError { - public PropertyError(String methodName, long seed, ShrinkResult shrunk) { - super(shrunk.thrown.getMessage() == null ? - String.format("%s(%s)\n\tSeed: %s", - methodName, join(", ", shrunk.args), - seed): - String.format("%s(%s)\n\tSeed: %s\n%s", - methodName, join(", ", shrunk.args), - seed, - shrunk.thrown.getMessage())); - initCause(shrunk.thrown); - } - - - public static String join(String delimiter, Object... params) { - return join(delimiter, Arrays.asList(params)); - } - - public static String join(String delimiter, Collection values) { - StringBuilder sb = new StringBuilder(); - Iterator iter = values.iterator(); - while (iter.hasNext()) { - Object next = iter.next(); - sb.append(stringValueOf(next)); - if (iter.hasNext()) { - sb.append(delimiter); - } - } - return sb.toString(); - } - - private static String stringValueOf(Object next) { - if (next instanceof String) { - return '"' + ((String) next).replace("\"", "\\\"") + '"'; - } else { - try { - return String.valueOf(next); - } catch (Throwable e) { - return "[toString failed]"; - } - } - } -} diff --git a/src/main/java/au/id/zancanaro/RoseTree.java b/src/main/java/au/id/zancanaro/RoseTree.java deleted file mode 100644 index 458d441..0000000 --- a/src/main/java/au/id/zancanaro/RoseTree.java +++ /dev/null @@ -1,103 +0,0 @@ -package au.id.zancanaro; - -import java.io.IOException; -import java.io.Writer; -import java.util.Collections; -import java.util.Iterator; -import java.util.function.Function; -import java.util.function.Predicate; - -public class RoseTree { - private final T value; - private final Iterable> children; - - public RoseTree(T value, Iterable> children) { - this.value = value; - this.children = children; - } - - public T getValue() { - return value; - } - - public Iterator> getChildren() { - return children.iterator(); - } - - public static RoseTree pure(T value) { - return new RoseTree<>(value, Collections.emptyList()); - } - - public static RoseTree join(RoseTree> tree) { - return new RoseTree<>( - tree.getValue().getValue(), - () -> Iterators.concat( - Iterators.mappingIterator(RoseTree::join, tree.children.iterator()), - tree.getValue().children.iterator())); - } - - public static Iterator[]> permutations(RoseTree[] trees) { - return Iterators.flatten(Iterators.rangeIterator(trees.length, index -> - Iterators.mappingIterator(child -> { - @SuppressWarnings("unchecked") - RoseTree[] result = (RoseTree[]) new RoseTree[trees.length]; - for (int i = 0; i < trees.length; ++i) { - result[i] = (i == index ? child : trees[i]); - } - return result; - }, trees[index].getChildren()) - )); - } - - public static RoseTree zip(Function fn, RoseTree[] trees) { - @SuppressWarnings("unchecked") - T[] heads = (T[]) new Object[trees.length]; - for (int i = 0; i < trees.length; ++i) { - heads[i] = trees[i].getValue(); - } - return new RoseTree<>( - fn.apply(heads), - () -> Iterators.mappingIterator( - roses -> RoseTree.zip(fn, roses), - RoseTree.permutations(trees))); - } - - public RoseTree fmap(Function f) { - return new RoseTree<>( - f.apply(this.value), - () -> Iterators.mappingIterator(tree -> tree.fmap(f), this.children.iterator())); - } - - public RoseTree flatmap(Function> f) { - return RoseTree.join(this.fmap(f)); - } - - public RoseTree filter(Predicate predicate) { - if (predicate.test(this.getValue())) { - return new RoseTree<>( - this.getValue(), - () -> Iterators.mappingIterator(tree -> tree.filter(predicate), - Iterators.filteringIterator( - tree -> predicate.test(tree.getValue()), - this.getChildren()))); - } else { - throw new IllegalArgumentException("Current value doesn't match predicate: whoops!"); - } - } - - @SuppressWarnings("unused") - public void print(Writer output) throws IOException { - print(output, Object::toString); - } - - @SuppressWarnings("unused") - public void print(Writer output, Function toString) throws IOException { - output.write(toString.apply(this.getValue())); - output.write('['); - for (RoseTree child : children) { - child.print(output, toString); - } - output.write(']'); - output.flush(); - } -} diff --git a/src/main/java/au/id/zancanaro/ShrinkResult.java b/src/main/java/au/id/zancanaro/ShrinkResult.java deleted file mode 100644 index fb2f2d4..0000000 --- a/src/main/java/au/id/zancanaro/ShrinkResult.java +++ /dev/null @@ -1,11 +0,0 @@ -package au.id.zancanaro; - -class ShrinkResult { - public final Object[] args; - public final Throwable thrown; - - public ShrinkResult(Object[] args, Throwable thrown) { - this.args = args; - this.thrown = thrown; - } -} diff --git a/src/main/java/au/id/zancanaro/annotations/DataSource.java b/src/main/java/au/id/zancanaro/annotations/DataSource.java deleted file mode 100644 index 9fe255b..0000000 --- a/src/main/java/au/id/zancanaro/annotations/DataSource.java +++ /dev/null @@ -1,11 +0,0 @@ -package au.id.zancanaro.annotations; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.FIELD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface DataSource { -} diff --git a/src/main/java/au/id/zancanaro/annotations/Property.java b/src/main/java/au/id/zancanaro/annotations/Property.java deleted file mode 100644 index f750596..0000000 --- a/src/main/java/au/id/zancanaro/annotations/Property.java +++ /dev/null @@ -1,13 +0,0 @@ -package au.id.zancanaro.annotations; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Property { - int maxSize() default 100; - int runs() default 100; -} diff --git a/src/main/java/au/id/zancanaro/annotations/Seed.java b/src/main/java/au/id/zancanaro/annotations/Seed.java deleted file mode 100644 index 99f00ca..0000000 --- a/src/main/java/au/id/zancanaro/annotations/Seed.java +++ /dev/null @@ -1,12 +0,0 @@ -package au.id.zancanaro.annotations; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Seed { - long value(); -} diff --git a/src/main/java/au/id/zancanaro/javacheck/Generator.java b/src/main/java/au/id/zancanaro/javacheck/Generator.java new file mode 100644 index 0000000..66ebf1b --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/Generator.java @@ -0,0 +1,36 @@ +package au.id.zancanaro.javacheck; + +import java.util.List; +import java.util.Random; +import java.util.function.Function; + +public interface Generator { + RoseTree generate(Random random, int size); + + static Generator pure(T value) { + return (random, size) -> RoseTree.pure(value); + } + + @SafeVarargs + static Generator> tuple(Generator... generators) { + return (random, size) -> { + @SuppressWarnings("unchecked") + RoseTree[] result = (RoseTree[]) new RoseTree[generators.length]; + int index = 0; + for (Generator generator : generators) { + result[index++] = generator.generate(random, size); + } + return RoseTree.zip(Function.identity(), result); + }; + } + + default Generator fmap(Function f) { + return (random, size) -> this.generate(random, size).fmap(f); + } + + default Generator flatMap(Function> action) { + return (random, size) -> { + return RoseTree.join(this.generate(random, size).fmap(action).fmap(g -> g.generate(random, size))); + }; + } +} diff --git a/src/main/java/au/id/zancanaro/javacheck/Generators.java b/src/main/java/au/id/zancanaro/javacheck/Generators.java new file mode 100644 index 0000000..ec6a329 --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/Generators.java @@ -0,0 +1,135 @@ +package au.id.zancanaro.javacheck; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +public final class Generators { + private Generators() { + } + + public static Generator sized(Function> makeGenerator) { + return (random, size) -> makeGenerator.apply(size).generate(random, size); + } + + public static Generator suchThat(Generator gen, Predicate pred) { + return (random, size) -> { + RoseTree result = gen.generate(random, size); + if (pred.test(result.getValue())) { + return result.filter(pred); + } else { + return suchThat(gen, pred).generate(random, size); + } + }; + } + + @SafeVarargs + public static Generator oneOf(Generator... gens) { + return integer(0, gens.length).flatMap(index -> gens[index]); + } + + public static Generator elements(T[] elements) { + return elements(Arrays.asList(elements)); + } + + public static Generator elements(List elements) { + return integer(0, elements.size()).fmap(elements::get); + } + + public static Generator bool() { + return (random, size) -> + random.nextBoolean() ? + new RoseTree<>(true, Collections.singletonList(new RoseTree<>(false, Collections.emptyList()))) : + new RoseTree<>(false, Collections.emptyList()); + } + + public static Generator integer(int lower, int upper) { + return (random, size) -> { + int value = lower + random.nextInt(upper - lower); + int bound = lower > 0 ? lower : (upper < 0 ? upper : 0); + return new RoseTree<>(value, intShrinkingIterable(value, bound)); + }; + } + + public static Generator integer() { + return sized(size -> integer(-size, size)); + } + + public static Generator natural() { + return sized(size -> integer(0, size)); + } + + private static Iterable> intShrinkingIterable(final int value, final int bound) { + return () -> new Iterator>() { + int curr = value - bound; + + @Override + public boolean hasNext() { + return curr != 0; + } + + @Override + public RoseTree next() { + int prevCurr = curr; + curr = curr / 2; + return new RoseTree<>(value - prevCurr, intShrinkingIterable(value - prevCurr, bound)); + } + }; + } + + public static Generator> listOf(Generator gen) { + return sized(size -> + integer(0, size).flatMap(count -> { + @SuppressWarnings("unchecked") + Generator[] gens = (Generator[]) new Generator[count]; + Arrays.fill(gens, gen); + return Generator.tuple(gens); + })).fmap(Collections::unmodifiableList); + } + + public static Generator character() { + return integer(0, 256).fmap(i -> (char) i.intValue()); + } + + public static Generator asciiCharacter() { + return integer(32, 127).fmap(i -> (char) i.intValue()); + } + + public static Generator alphaNumericCharacter() { + return oneOf( + integer(48, 58), + integer(65, 91), + integer(97, 123)).fmap(i -> (char) i.intValue()); + } + + public static Generator alphaCharacter() { + return oneOf( + integer(65, 91), + integer(97, 123)).fmap(i -> (char) i.intValue()); + } + + private static String makeString(Character[] arr) { + StringBuilder builder = new StringBuilder(arr.length); + for (Character c : arr) { + builder.append(c); + } + return builder.toString(); + } + + public static Generator string() { + return stringOf(character()); + } + + public static Generator stringOf(Generator charGen) { + return Generators.listOf(charGen).fmap(list -> { + StringBuilder builder = new StringBuilder(list.size()); + for (Object c : list) { + builder.append(c); + } + return builder.toString(); + }); + } +} diff --git a/src/main/java/au/id/zancanaro/javacheck/Iterators.java b/src/main/java/au/id/zancanaro/javacheck/Iterators.java new file mode 100644 index 0000000..347a927 --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/Iterators.java @@ -0,0 +1,177 @@ +package au.id.zancanaro.javacheck; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +public final class Iterators { + private Iterators() { + } + + public static RangeIterator rangeIterator(int countTo, Function fn) { + return new RangeIterator(countTo, fn); + } + + private static class RangeIterator implements Iterator { + private final Function action; + private final int countTo; + private int index = 0; + + public RangeIterator(int countTo, Function action) { + this.countTo = countTo; + this.action = action; + } + + @Override + public boolean hasNext() { + return index < countTo; + } + + @Override + public T next() { + return action.apply(index++); + } + } + + public static FlattenIterator flatten(Iterator> iterators) { + return new FlattenIterator<>(iterators); + } + public static class FlattenIterator implements Iterator { + private Iterator current; + + private Iterator> iterators; + + public FlattenIterator(Iterator> iterators) { + this.current = Iterators.emptyIterator(); + this.iterators = iterators; + } + + private Iterator getCurrent() { + while (!current.hasNext() && iterators.hasNext()) { + current = iterators.next(); + } + return current; + } + + @Override + public boolean hasNext() { + return getCurrent().hasNext(); + } + @Override + public T next() { + return getCurrent().next(); + } + + } + + public static ConcatIterator concat(Iterator left, Iterator right) { + return new ConcatIterator<>(left, right); + } + public static class ConcatIterator implements Iterator { + private final Iterator left; + + private final Iterator right; + + public ConcatIterator(Iterator left, Iterator right) { + this.left = left; + this.right = right; + } + + @Override + public boolean hasNext() { + return left.hasNext() || right.hasNext(); + } + @Override + public T next() { + if (left.hasNext()) { + return left.next(); + } else { + return right.next(); + } + } + + } + + public static EmptyIterator emptyIterator() { + return new EmptyIterator<>(); + } + public static class EmptyIterator implements Iterator { + + @Override + public boolean hasNext() { + return false; + } + @Override + public T next() { + return null; + } + + } + + public static MappingIterator mappingIterator(Function f, Iterator iterator) { + return new MappingIterator<>(f, iterator); + } + private static class MappingIterator implements Iterator { + private final Function mapping; + + private final Iterator iterator; + + public MappingIterator(Function mapping, Iterator iterator) { + this.mapping = mapping; + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + @Override + public R next() { + return mapping.apply(iterator.next()); + } + + } + + public static FilteringIterator filteringIterator(Predicate pred, Iterator iterator) { + return new FilteringIterator<>(pred, iterator); + } + + private static class FilteringIterator implements Iterator { + private final Predicate predicate; + private final Iterator iterator; + private List nextValue; + + public FilteringIterator(Predicate pred, Iterator iterator) { + this.predicate = pred; + this.iterator = iterator; + this.nextValue = null; + } + + private void populateNext() { + while (nextValue == null && iterator.hasNext()) { + T value = iterator.next(); + if (predicate.test(value)) { + nextValue = Collections.singletonList(value); + } else { + nextValue = null; + } + } + } + + @Override + public boolean hasNext() { + populateNext(); + return nextValue != null; + } + + @Override + public T next() { + populateNext(); + T result = nextValue.get(0); + nextValue = null; + return result; + } + } +} diff --git a/src/main/java/au/id/zancanaro/javacheck/RoseTree.java b/src/main/java/au/id/zancanaro/javacheck/RoseTree.java new file mode 100644 index 0000000..d9fb508 --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/RoseTree.java @@ -0,0 +1,105 @@ +package au.id.zancanaro.javacheck; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +public class RoseTree { + private final T value; + private final Iterable> children; + + public RoseTree(T value, Iterable> children) { + this.value = value; + this.children = children; + } + + public T getValue() { + return value; + } + + public Iterator> getChildren() { + return children.iterator(); + } + + public static RoseTree pure(T value) { + return new RoseTree<>(value, Collections.emptyList()); + } + + public static RoseTree join(RoseTree> tree) { + return new RoseTree<>( + tree.getValue().getValue(), + () -> Iterators.concat( + Iterators.mappingIterator(RoseTree::join, tree.children.iterator()), + tree.getValue().children.iterator())); + } + + public static Iterator[]> permutations(RoseTree[] trees) { + return Iterators.flatten(Iterators.rangeIterator(trees.length, index -> + Iterators.mappingIterator(child -> { + @SuppressWarnings("unchecked") + RoseTree[] result = (RoseTree[]) new RoseTree[trees.length]; + for (int i = 0; i < trees.length; ++i) { + result[i] = (i == index ? child : trees[i]); + } + return result; + }, trees[index].getChildren()) + )); + } + + public static RoseTree zip(Function, R> fn, RoseTree[] trees) { + @SuppressWarnings("unchecked") + List heads = new ArrayList(trees.length); + for (int i = 0; i < trees.length; ++i) { + heads.add(trees[i].getValue()); + } + return new RoseTree<>( + fn.apply(heads), + () -> Iterators.mappingIterator( + roses -> RoseTree.zip(fn, roses), + RoseTree.permutations(trees))); + } + + public RoseTree fmap(Function f) { + return new RoseTree<>( + f.apply(this.value), + () -> Iterators.mappingIterator(tree -> tree.fmap(f), this.children.iterator())); + } + + public RoseTree flatmap(Function> f) { + return RoseTree.join(this.fmap(f)); + } + + public RoseTree filter(Predicate predicate) { + if (predicate.test(this.getValue())) { + return new RoseTree<>( + this.getValue(), + () -> Iterators.mappingIterator(tree -> tree.filter(predicate), + Iterators.filteringIterator( + tree -> predicate.test(tree.getValue()), + this.getChildren()))); + } else { + throw new IllegalArgumentException("Current value doesn't match predicate: whoops!"); + } + } + + @SuppressWarnings("unused") + public void print(Writer output) throws IOException { + print(output, Object::toString); + } + + @SuppressWarnings("unused") + public void print(Writer output, Function toString) throws IOException { + output.write(toString.apply(this.getValue())); + output.write('['); + for (RoseTree child : children) { + child.print(output, toString); + } + output.write(']'); + output.flush(); + } +} diff --git a/src/main/java/au/id/zancanaro/javacheck/ShrinkResult.java b/src/main/java/au/id/zancanaro/javacheck/ShrinkResult.java new file mode 100644 index 0000000..e1463f0 --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/ShrinkResult.java @@ -0,0 +1,11 @@ +package au.id.zancanaro.javacheck; + +public class ShrinkResult { + public final Object[] args; + public final Throwable thrown; + + public ShrinkResult(Object[] args, Throwable thrown) { + this.args = args; + this.thrown = thrown; + } +} diff --git a/src/main/java/au/id/zancanaro/javacheck/annotations/DataSource.java b/src/main/java/au/id/zancanaro/javacheck/annotations/DataSource.java new file mode 100644 index 0000000..a954c1b --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/annotations/DataSource.java @@ -0,0 +1,11 @@ +package au.id.zancanaro.javacheck.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface DataSource { +} diff --git a/src/main/java/au/id/zancanaro/javacheck/annotations/Property.java b/src/main/java/au/id/zancanaro/javacheck/annotations/Property.java new file mode 100644 index 0000000..aae4ccd --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/annotations/Property.java @@ -0,0 +1,13 @@ +package au.id.zancanaro.javacheck.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Property { + int maxSize() default 100; + int runs() default 100; +} diff --git a/src/main/java/au/id/zancanaro/javacheck/annotations/Seed.java b/src/main/java/au/id/zancanaro/javacheck/annotations/Seed.java new file mode 100644 index 0000000..ee8c739 --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/annotations/Seed.java @@ -0,0 +1,12 @@ +package au.id.zancanaro.javacheck.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Seed { + long value(); +} diff --git a/src/main/java/au/id/zancanaro/javacheck/junit/Properties.java b/src/main/java/au/id/zancanaro/javacheck/junit/Properties.java new file mode 100644 index 0000000..271e763 --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/junit/Properties.java @@ -0,0 +1,282 @@ +package au.id.zancanaro.javacheck.junit; + +import au.id.zancanaro.javacheck.Generator; +import au.id.zancanaro.javacheck.RoseTree; +import au.id.zancanaro.javacheck.ShrinkResult; +import au.id.zancanaro.javacheck.annotations.DataSource; +import au.id.zancanaro.javacheck.annotations.Property; +import au.id.zancanaro.javacheck.annotations.Seed; +import org.junit.AssumptionViolatedException; +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.Statement; +import org.junit.runners.model.TestClass; + +import java.lang.reflect.*; +import java.util.*; + + +public class Properties extends BlockJUnit4ClassRunner { + private final Map> generators = new HashMap<>(); + + public Properties(Class klass) throws InitializationError { + super(klass); + } + + @Override + protected void collectInitializationErrors(List errors) { + super.collectInitializationErrors(errors); + Set generated = validateGeneratorFields(errors); + validateTestMethodParameters(errors, generated); + } + + private Set validateGeneratorFields(List errors) { + Set result = new HashSet<>(); + Field[] fields = getTestClass().getJavaClass().getDeclaredFields(); + + for (Field field : fields) { + if (!field.isAnnotationPresent(DataSource.class)) { + continue; + } + Type type = field.getGenericType(); + if (!(type instanceof ParameterizedType)) { + continue; + } + ParameterizedType ptype = (ParameterizedType) type; + if (!(ptype.getRawType() instanceof Class)) { + continue; + } + Class c = (Class) ptype.getRawType(); + if (c != Generator.class) { + continue; + } + boolean error = false; + if (!Modifier.isStatic(field.getModifiers())) { + errors.add(new Error("Generator field " + field.getName() + " must be static")); + error = true; + } + if (!Modifier.isPublic(field.getModifiers())) { + errors.add(new Error("Generator field " + field.getName() + " must be public")); + error = true; + } + if (!error) { + result.add(ptype.getActualTypeArguments()[0]); + } + } + return result; + } + + private void validateTestMethodParameters(List errors, Set generated) { + for (FrameworkMethod each : computeTestMethods()) { + for (Type type : each.getMethod().getGenericParameterTypes()) { + if (!generated.contains(type)) { + errors.add(new Error("No @DataSource for type: " + type)); + generated.add(type); // ignore future errors on this type + } + } + } + } + + private static final Map rawTypes; + + static { + Map types = new HashMap<>(); + types.put(Double.class, Double.TYPE); + types.put(Float.class, Float.TYPE); + types.put(Long.class, Long.TYPE); + types.put(Integer.class, Integer.TYPE); + types.put(Short.class, Short.TYPE); + types.put(Byte.class, Byte.TYPE); + types.put(Character.class, Character.TYPE); + types.put(Boolean.class, Boolean.TYPE); + rawTypes = Collections.unmodifiableMap(types); + } + + private Map> computeGenerators() { + if (generators.isEmpty()) { + Field[] fields = getTestClass().getJavaClass().getDeclaredFields(); + + for (Field field : fields) { + if (!field.isAnnotationPresent(DataSource.class)) { + continue; + } + Type type = field.getGenericType(); + if (!(type instanceof ParameterizedType)) { + continue; + } + ParameterizedType ptype = (ParameterizedType) type; + if (!(ptype.getRawType() instanceof Class)) { + continue; + } + Class c = (Class) ptype.getRawType(); + if (c != Generator.class) { + continue; + } + try { + Type target = ptype.getActualTypeArguments()[0]; + @SuppressWarnings("unchecked") + Generator generator = (Generator) field.get(null); + generators.put(target, generator); + if (rawTypes.containsKey(target)) { + generators.put(rawTypes.get(target), generator); + } + } catch (IllegalAccessException ex) { + + } + } + } + return generators; + } + + @Override + protected void validateConstructor(List errors) { + validateOnlyOneConstructor(errors); + } + + @Override + protected void validateTestMethods(List errors) { + for (FrameworkMethod each : computeTestMethods()) { + if (each.getAnnotation(Property.class) != null) { + each.validatePublicVoid(false, errors); + each.validateNoTypeParametersOnArgs(errors); + } else { + each.validatePublicVoidNoArg(false, errors); + } + } + } + + @Override + protected List computeTestMethods() { + List testMethods = new ArrayList<>(super.computeTestMethods()); + List theoryMethods = getTestClass().getAnnotatedMethods(Property.class); + testMethods.removeAll(theoryMethods); + testMethods.addAll(theoryMethods); + return testMethods; + } + + @Override + public Statement methodBlock(final FrameworkMethod method) { + return new GenerativeTester(method, getTestClass(), computeGenerators()); + } + + public static class GenerativeTester extends Statement { + private final FrameworkMethod testMethod; + private final TestClass testClass; + private final Map> generators; + + public GenerativeTester(FrameworkMethod testMethod, TestClass testClass, Map> generators) { + this.testMethod = testMethod; + this.testClass = testClass; + this.generators = generators; + } + + private long getSeed(Method method) { + Seed seed = method.getAnnotation(Seed.class); + if (seed == null) { + return System.currentTimeMillis(); + } else { + return seed.value(); + } + } + + @Override + public void evaluate() throws Throwable { + Method method = testMethod.getMethod(); + if (method.getParameterCount() == 0) { + runTest(new Object[0]); + } else { + @SuppressWarnings("unchecked") + Generator[] generators = (Generator[]) new Generator[method.getParameterCount()]; + int index = 0; + for (Type type : method.getGenericParameterTypes()) { + // TODO: validate ahead of time that this generator will exist (ideally in the constructor validation time) + generators[index++] = this.generators.get(type); + } + @SuppressWarnings("unchecked") + Generator generator = Generator.tuple((Generator[]) generators).fmap(List::toArray); + + long seed = getSeed(method); + Random random = new Random(seed); + + Property property = testMethod.getAnnotation(Property.class); + int assumptionsViolated = 0; + int maxSize = property.maxSize(); + int numTests = property.runs(); + for (int i = 0; i < numTests; ++i) { + int size = Math.min(i + 1, maxSize); + RoseTree tree = generator.generate(random, size); + try { + runTest(tree.getValue()); + assumptionsViolated = 0; + } catch (AssumptionViolatedException ex) { + numTests++; + if (assumptionsViolated++ == 50) { + throw new Error("Violated 50 assumptions in a row: failing test"); + } + ; + } catch (Throwable ex) { +// tree.print(new OutputStreamWriter(System.out), Arrays::toString); + throw new PropertyError(method.getName(), seed, shrink(tree, ex)); + } + } + } + } + + private ShrinkResult shrink(RoseTree failed, Throwable originalEx) { + ShrinkResult smallest = new ShrinkResult(failed.getValue(), originalEx); + Iterator> trees = failed.getChildren(); + Set> seenArgs = new HashSet<>(); + while (trees.hasNext()) { + RoseTree tree = trees.next(); + if (seenArgs.add(Arrays.asList(tree.getValue()))) { + try { + runTest(tree.getValue()); + } catch (AssumptionViolatedException ex) { + // ignore, because it's not useful + } catch (Throwable ex) { + smallest = new ShrinkResult(tree.getValue(), ex); + Iterator> children = tree.getChildren(); + if (children.hasNext()) { + trees = children; + } else { + break; + } + } + } + } + return smallest; + } + + public void runTest(final Object[] args) throws Throwable { + new BlockJUnit4ClassRunner(testClass.getJavaClass()) { + @Override + protected void collectInitializationErrors( + List errors) { + // do nothing + } + + @Override + public Statement methodBlock(FrameworkMethod method) { + return super.methodBlock(method); + } + + @Override + protected Statement methodInvoker(FrameworkMethod method, Object test) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + method.invokeExplosively(test, args); + } + }; + } + + @Override + public Object createTest() throws Exception { + return getTestClass().getOnlyConstructor().newInstance(); + } + }.methodBlock(testMethod).evaluate(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/au/id/zancanaro/javacheck/junit/PropertyError.java b/src/main/java/au/id/zancanaro/javacheck/junit/PropertyError.java new file mode 100644 index 0000000..138c47f --- /dev/null +++ b/src/main/java/au/id/zancanaro/javacheck/junit/PropertyError.java @@ -0,0 +1,51 @@ +package au.id.zancanaro.javacheck.junit; + +import au.id.zancanaro.javacheck.ShrinkResult; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; + +public class PropertyError extends AssertionError { + public PropertyError(String methodName, long seed, ShrinkResult shrunk) { + super(shrunk.thrown.getMessage() == null ? + String.format("%s(%s)\n\tSeed: %s", + methodName, join(", ", shrunk.args), + seed): + String.format("%s(%s)\n\tSeed: %s\n%s", + methodName, join(", ", shrunk.args), + seed, + shrunk.thrown.getMessage())); + initCause(shrunk.thrown); + } + + + public static String join(String delimiter, Object... params) { + return join(delimiter, Arrays.asList(params)); + } + + public static String join(String delimiter, Collection values) { + StringBuilder sb = new StringBuilder(); + Iterator iter = values.iterator(); + while (iter.hasNext()) { + Object next = iter.next(); + sb.append(stringValueOf(next)); + if (iter.hasNext()) { + sb.append(delimiter); + } + } + return sb.toString(); + } + + private static String stringValueOf(Object next) { + if (next instanceof String) { + return '"' + ((String) next).replace("\"", "\\\"") + '"'; + } else { + try { + return String.valueOf(next); + } catch (Throwable e) { + return "[toString failed]"; + } + } + } +} -- cgit v1.2.3