Repository: graphhopper/graphhopper
Pull request #2214 (2020-12-16 20:24:06)

Log MultiException properly
Our `MultiException` constructor calls the default `RuntimeException` constructor , so the `detailsMessage` field in `Throwable` is always `null`. Therefore we log `unknown reason` for many errors. I changed this so we see more details on e.g. 400 errors like this:

```
# before
com.graphhopper.http.MultiExceptionMapper: bad request: unknown reason

# after
com.graphhopper.http.MultiExceptionMapper: bad request: [java.lang.IllegalArgumentException: The 'heading' parameter is currently not supported for speed mode, you need to disable speed mode with `ch.disable=true`. See issue #483]
```

Not sure, we could also properly set the message in `MultiException` and skip `java.lang.IllegalArgumentException` for the output, but this seemed like the minimal fix..

michaz: Thanks! Wait, could we instead change MultiException, please? I was lazy there.

It's a common pattern, we have at least two MultiExceptions in our classpath, one from Jetty and one from HK2 via Jersey via Dropwizard.

Could you try to copy/paste more construction from there to our MultiException until it behaves nicely? (Not a lot I think..)
michaz: (No reason _not_ to do also do something in the Logger, of course..)

Patch:
diff --git a/README.md b/README.md
index 478417bc2..63b4022f6 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ We have a prospering community and welcome everyone. Let us know your problems,
 
 ## Questions
 
-All questions go to our [forum](https://discuss.graphhopper.com/) where we also have subsections specially for developers, mobile usage, and [our map matching component](https://github.com/graphhopper/map-matching). Another place to ask questions
+All questions go to our [forum](https://discuss.graphhopper.com/) where we also have subsections specially for developers, mobile usage, and [our map matching component](./map-matching). Another place to ask questions
 is on [Stackoverflow](http://stackoverflow.com/questions/tagged/graphhopper). Do **not** use our issue section for questions.
 
 ## Contribute
@@ -23,7 +23,7 @@ like finding and fixing bugs and improving our documentation or translations!
 
 To get started you can try [GraphHopper Maps](README.md#graphhopper-maps), read through our documentation and install the GraphHopper Web Service locally.
 
- * stable 2.x: [documentation](https://github.com/graphhopper/graphhopper/blob/2.x/docs/index.md), [web service jar](https://graphhopper.com/public/releases/graphhopper-web-2.2.jar), [announcement](https://www.graphhopper.com/blog/2020/09/30/graphhopper-routing-engine-2-0-released/)
+ * stable 2.x: [documentation](https://github.com/graphhopper/graphhopper/blob/2.x/docs/index.md), [web service jar](https://graphhopper.com/public/releases/graphhopper-web-2.3.jar), [announcement](https://www.graphhopper.com/blog/2020/09/30/graphhopper-routing-engine-2-0-released/)
  * unstable master: [documentation](https://github.com/graphhopper/graphhopper/blob/master/docs/index.md)
 
 <details><summary>Click to see older releases</summary>
@@ -45,7 +45,7 @@ To install the [GraphHopper Maps](https://graphhopper.com/maps/) and the web ser
 
 ```bash
 # download and install a JVM that supports at least Java 8 (https://adoptopenjdk.net)
-wget https://graphhopper.com/public/releases/graphhopper-web-2.0.jar https://raw.githubusercontent.com/graphhopper/graphhopper/stable/config-example.yml http://download.geofabrik.de/europe/germany/berlin-latest.osm.pbf
+wget https://graphhopper.com/public/releases/graphhopper-web-2.3.jar https://raw.githubusercontent.com/graphhopper/graphhopper/stable/config-example.yml http://download.geofabrik.de/europe/germany/berlin-latest.osm.pbf
 java -Ddw.graphhopper.datareader.file=berlin-latest.osm.pbf -jar *.jar server config-example.yml
 ```
 
@@ -210,7 +210,7 @@ Here is a list of the more detailed features:
  * The core uses only a few dependencies (hppc, jts and slf4j)
  * Scales from small indoor-sized to world-wide-sized graphs
  * Finds nearest point on street e.g. to get elevation or 'snap to road' or being used as spatial index (see [#1485](https://github.com/graphhopper/graphhopper/pull/1485))
- * Does [map matching](https://github.com/graphhopper/map-matching)
+ * Does [map matching](./map-matching)
  * Calculates isochrones and [shortest path trees](https://github.com/graphhopper/graphhopper/pull/1577)
  * Shows the whole road network in the browser for debugging purposes ("vector tile support") [#1572](https://github.com/graphhopper/graphhopper/pull/1572)
  * Shows details along a route like road_class or max_speed ("path details") [#1142](https://github.com/graphhopper/graphhopper/pull/1142) or for the whole road network via vector tiles
diff --git a/core/src/main/java/com/graphhopper/routing/AStarBidirectionEdgeCHNoSOD.java b/core/src/main/java/com/graphhopper/routing/AStarBidirectionEdgeCHNoSOD.java
index 75aa06d97..f842a90b4 100644
--- a/core/src/main/java/com/graphhopper/routing/AStarBidirectionEdgeCHNoSOD.java
+++ b/core/src/main/java/com/graphhopper/routing/AStarBidirectionEdgeCHNoSOD.java
@@ -43,15 +43,25 @@ public class AStarBidirectionEdgeCHNoSOD extends AbstractBidirectionEdgeCHNoSOD
     }
 
     @Override
-    protected boolean fwdSearchCanBeStopped() {
+    protected boolean fromEntryCanBeSkipped() {
         return getMinCurrFromPathWeight() > bestWeight;
     }
 
     @Override
-    protected boolean bwdSearchCanBeStopped() {
+    protected boolean toEntryCanBeSkipped() {
         return getMinCurrToPathWeight() > bestWeight;
     }
 
+    @Override
+    protected boolean fwdSearchCanBeStopped() {
+        return useHeuristicForNodeOrder && currFrom.weight > bestWeight;
+    }
+
+    @Override
+    protected boolean bwdSearchCanBeStopped() {
+        return useHeuristicForNodeOrder && currTo.weight > bestWeight;
+    }
+
     @Override
     protected AStarCHEntry createStartEntry(int node, double weight, boolean reverse) {
         final double heapWeight = getHeapWeight(node, reverse, weight);
diff --git a/core/src/main/java/com/graphhopper/routing/ch/CHRoutingAlgorithmFactory.java b/core/src/main/java/com/graphhopper/routing/ch/CHRoutingAlgorithmFactory.java
index ba36e5e89..8c8a79894 100644
--- a/core/src/main/java/com/graphhopper/routing/ch/CHRoutingAlgorithmFactory.java
+++ b/core/src/main/java/com/graphhopper/routing/ch/CHRoutingAlgorithmFactory.java
@@ -55,9 +55,7 @@ public class CHRoutingAlgorithmFactory {
     }
 
     private BidirRoutingAlgorithm createAlgoEdgeBased(RoutingCHGraph g, PMap opts) {
-        // todo: AStar is much faster for edge-based but currently we cannot make it the default because
-        //       of #2061
-        String defaultAlgo = DIJKSTRA_BI;
+        String defaultAlgo = ASTAR_BI;
         String algo = opts.getString(ALGORITHM, defaultAlgo);
         if (Helper.isEmpty(algo))
             algo = defaultAlgo;
diff --git a/core/src/main/java/com/graphhopper/routing/ev/EncodedValueLookup.java b/core/src/main/java/com/graphhopper/routing/ev/EncodedValueLookup.java
index 52a8d2f0b..c67f696c6 100644
--- a/core/src/main/java/com/graphhopper/routing/ev/EncodedValueLookup.java
+++ b/core/src/main/java/com/graphhopper/routing/ev/EncodedValueLookup.java
@@ -31,7 +31,9 @@ public interface EncodedValueLookup {
 
     DecimalEncodedValue getDecimalEncodedValue(String key);
 
-    <T extends Enum> EnumEncodedValue<T> getEnumEncodedValue(String key, Class<T> enumType);
+    <T extends Enum<?>> EnumEncodedValue<T> getEnumEncodedValue(String key, Class<T> enumType);
+    
+    StringEncodedValue getStringEncodedValue(String key);
 
     boolean hasEncodedValue(String key);
 }
diff --git a/core/src/main/java/com/graphhopper/routing/ev/StringEncodedValue.java b/core/src/main/java/com/graphhopper/routing/ev/StringEncodedValue.java
new file mode 100644
index 000000000..515ea23f0
--- /dev/null
+++ b/core/src/main/java/com/graphhopper/routing/ev/StringEncodedValue.java
@@ -0,0 +1,123 @@
+package com.graphhopper.routing.ev;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import com.carrotsearch.hppc.ObjectIntHashMap;
+import com.carrotsearch.hppc.ObjectIntMap;
+import com.graphhopper.storage.IntsRef;
+
+/**
+ * This class holds a List of up to {@link #maxValues} encountered Strings and stores
+ * <i>index+1</i> to indicate a string is set or <i>0</i> if no value is assigned
+ * 
+ * @author Peter Karich
+ * @author Thomas Butz
+ */
+public final class StringEncodedValue extends UnsignedIntEncodedValue {
+    private final int maxValues;
+    private final List<String> values;
+    private final ObjectIntMap<String> indexMap;
+
+    public StringEncodedValue(String name, int expectedValueCount) {
+        this(name, expectedValueCount, false);
+    }
+
+    public StringEncodedValue(String name, int expectedValueCount, boolean storeTwoDirections) {
+        super(name, 32 - Integer.numberOfLeadingZeros(expectedValueCount), storeTwoDirections);
+
+        this.maxValues = roundUp(expectedValueCount);
+        this.values = new ArrayList<>(maxValues);
+        this.indexMap = new ObjectIntHashMap<>(maxValues);
+    }
+
+    public StringEncodedValue(String name, int bits, List<String> values, boolean storeTwoDirections) {
+        super(name, bits, storeTwoDirections);
+
+        this.maxValues = (1 << bits) - 1;
+        if (values.size() > maxValues) {
+            throw new IllegalArgumentException(
+                            "Number of values is higher than the maximum value count: "
+                                            + values.size() + " > " + maxValues);
+        }
+        this.values = new ArrayList<>(values);
+        this.indexMap = new ObjectIntHashMap<>(values.size());
+        int index = 1;
+        for (String value : values) {
+            indexMap.put(value, index++);
+        }
+    }
+
+    public final void setString(boolean reverse, IntsRef ref, String value) {
+        if (value == null) {
+            super.setInt(reverse, ref, 0);
+            return;
+        }
+        int index = indexMap.get(value);
+        if (index == 0) {
+            if (values.size() == maxValues) {
+                throw new IllegalStateException("Maximum number of values reached for " + getName()
+                                + ": " + maxValues);
+            }
+            values.add(value);
+            index = values.size();
+            indexMap.put(value, index);
+        }
+        super.setInt(reverse, ref, index);
+    }
+
+    public final String getString(boolean reverse, IntsRef ref) {
+        int value = super.getInt(reverse, ref);
+        if (value == 0) {
+            return null;
+        }
+        return values.get(value - 1);
+    }
+
+    /**
+     * @param value
+     *            the value to be rounded
+     * @return the value rounded to the highest integer with the same number of leading zeros
+     */
+    private static int roundUp(int value) {
+        return -1 >>> Integer.numberOfLeadingZeros(value);
+    }
+
+    /**
+     * @param value
+     *            the String to retrieve the index
+     * @return the non-zero index of the String or <i>0</i> if it couldn't be found
+     */
+    public int indexOf(String value) {
+        return indexMap.get(value);
+    }
+
+    /**
+     * @return an unmodifiable List of the current values
+     */
+    public List<String> getValues() {
+        return Collections.unmodifiableList(values);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof StringEncodedValue)) {
+            return false;
+        }
+        StringEncodedValue other = (StringEncodedValue) obj;
+        if (this.bits != other.bits) {
+            return false;
+        }
+        return Objects.equals(values, other.values);
+    }
+
+    @Override
+    public int getVersion() {
+        return 31 * super.getVersion() + staticHashCode(values);
+    }
+}
diff --git a/core/src/main/java/com/graphhopper/routing/ev/UnsignedIntEncodedValue.java b/core/src/main/java/com/graphhopper/routing/ev/UnsignedIntEncodedValue.java
index 4fb107ba9..62185fca6 100644
--- a/core/src/main/java/com/graphhopper/routing/ev/UnsignedIntEncodedValue.java
+++ b/core/src/main/java/com/graphhopper/routing/ev/UnsignedIntEncodedValue.java
@@ -22,7 +22,7 @@ import com.graphhopper.storage.IntsRef;
 import com.graphhopper.util.Helper;
 
 import java.util.Arrays;
-import java.util.Locale;
+import java.util.Collection;
 import java.util.Objects;
 
 /**
@@ -207,7 +207,7 @@ public class UnsignedIntEncodedValue implements IntEncodedValue {
      * Produces a static hashcode for an Enum arrays that is platform independent and still compatible to the default
      * of openjdk.
      */
-    static int staticHashCode(Enum... vals) {
+    static int staticHashCode(Enum<?>... vals) {
         if (vals == null)
             return 0;
         int len = vals.length;
@@ -219,6 +219,37 @@ public class UnsignedIntEncodedValue implements IntEncodedValue {
         return val;
     }
 
+    /**
+     * Produces a static hashcode for an String arrays that is platform independent and still compatible to the default
+     * of openjdk.
+     */
+    static int staticHashCode(String... vals) {
+        if (vals == null)
+            return 0;
+        int len = vals.length;
+        int val = 1;
+        for (int idx = 0; idx < len; ++idx) {
+            val = 31 * val + Helper.staticHashCode(vals[idx]);
+        }
+
+        return val;
+    }
+    
+    /**
+     * Produces a static hashcode for a collection of Strings that is platform independent and still compatible to the default
+     * of openjdk.
+     */
+    static int staticHashCode(Collection<String> vals) {
+        if (vals == null)
+            return 0;
+        int val = 1;
+        for (String str : vals) {
+            val = 31 * val + Helper.staticHashCode(str);
+        }
+
+        return val;
+    }
+
     /**
      * Produces a static hashcode for an integer arrays that is platform independent and still compatible to the default
      * of openjdk
diff --git a/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIterator.java b/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIterator.java
index 181d3ce54..aeecd2b62 100644
--- a/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIterator.java
+++ b/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIterator.java
@@ -21,6 +21,7 @@ import com.graphhopper.routing.ev.BooleanEncodedValue;
 import com.graphhopper.routing.ev.DecimalEncodedValue;
 import com.graphhopper.routing.ev.EnumEncodedValue;
 import com.graphhopper.routing.ev.IntEncodedValue;
+import com.graphhopper.routing.ev.StringEncodedValue;
 import com.graphhopper.routing.util.EdgeFilter;
 import com.graphhopper.storage.IntsRef;
 import com.graphhopper.util.EdgeIterator;
@@ -201,32 +202,57 @@ class VirtualEdgeIterator implements EdgeIterator {
     }
 
     @Override
-    public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+    public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
         getCurrentEdge().set(property, value);
         return this;
     }
 
     @Override
-    public <T extends Enum> T get(EnumEncodedValue<T> property) {
+    public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
         return getCurrentEdge().get(property);
     }
 
     @Override
-    public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+    public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
         getCurrentEdge().setReverse(property, value);
         return this;
     }
 
     @Override
-    public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+    public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
         return getCurrentEdge().getReverse(property);
     }
 
     @Override
-    public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+    public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
         getCurrentEdge().set(property, fwd, bwd);
         return this;
     }
+    
+    @Override
+    public String get(StringEncodedValue property) {
+        return getCurrentEdge().get(property);
+    }
+    
+    @Override
+    public EdgeIteratorState set(StringEncodedValue property, String value) {
+        return getCurrentEdge().set(property, value);
+    }
+    
+    @Override
+    public String getReverse(StringEncodedValue property) {
+        return getCurrentEdge().getReverse(property);
+    }
+    
+    @Override
+    public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+        return getCurrentEdge().setReverse(property, value);
+    }
+    
+    @Override
+    public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
+        return getCurrentEdge().set(property, fwd, bwd);
+    }
 
     @Override
     public String getName() {
diff --git a/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIteratorState.java b/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIteratorState.java
index 44fad7489..7a1a93afc 100644
--- a/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIteratorState.java
+++ b/core/src/main/java/com/graphhopper/routing/querygraph/VirtualEdgeIteratorState.java
@@ -21,6 +21,7 @@ import com.graphhopper.routing.ev.BooleanEncodedValue;
 import com.graphhopper.routing.ev.DecimalEncodedValue;
 import com.graphhopper.routing.ev.EnumEncodedValue;
 import com.graphhopper.routing.ev.IntEncodedValue;
+import com.graphhopper.routing.ev.StringEncodedValue;
 import com.graphhopper.storage.IntsRef;
 import com.graphhopper.util.EdgeIteratorState;
 import com.graphhopper.util.FetchMode;
@@ -242,35 +243,66 @@ public class VirtualEdgeIteratorState implements EdgeIteratorState {
     }
 
     @Override
-    public <T extends Enum> T get(EnumEncodedValue<T> property) {
+    public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
         return property.getEnum(reverse, edgeFlags);
     }
 
     @Override
-    public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+    public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
         property.setEnum(reverse, edgeFlags, value);
         return this;
     }
 
     @Override
-    public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+    public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
         return property.getEnum(!reverse, edgeFlags);
     }
 
     @Override
-    public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+    public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
         property.setEnum(!reverse, edgeFlags, value);
         return this;
     }
 
     @Override
-    public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+    public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
         if (!property.isStoreTwoDirections())
             throw new IllegalArgumentException("EncodedValue " + property.getName() + " supports only one direction");
         property.setEnum(reverse, edgeFlags, fwd);
         property.setEnum(!reverse, edgeFlags, bwd);
         return this;
     }
+    
+    @Override
+    public String get(StringEncodedValue property) {
+        return property.getString(reverse, edgeFlags);
+    }
+    
+    @Override
+    public EdgeIteratorState set(StringEncodedValue property, String value) {
+        property.setString(reverse, edgeFlags, value);
+        return this;
+    }
+    
+    @Override
+    public String getReverse(StringEncodedValue property) {
+        return property.getString(!reverse, edgeFlags);
+    }
+    
+    @Override
+    public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+        property.setString(!reverse, edgeFlags, value);
+        return this;
+    }
+    
+    @Override
+    public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
+        if (!property.isStoreTwoDirections())
+            throw new IllegalArgumentException("EncodedValue " + property.getName() + " supports only one direction");
+        property.setString(reverse, edgeFlags, fwd);
+        property.setString(!reverse, edgeFlags, bwd);
+        return this;
+    }
 
     @Override
     public String getName() {
diff --git a/core/src/main/java/com/graphhopper/routing/util/AbstractFlagEncoder.java b/core/src/main/java/com/graphhopper/routing/util/AbstractFlagEncoder.java
index d2422290e..1720cf617 100644
--- a/core/src/main/java/com/graphhopper/routing/util/AbstractFlagEncoder.java
+++ b/core/src/main/java/com/graphhopper/routing/util/AbstractFlagEncoder.java
@@ -410,9 +410,14 @@ public abstract class AbstractFlagEncoder implements FlagEncoder {
     }
 
     @Override
-    public <T extends Enum> EnumEncodedValue<T> getEnumEncodedValue(String key, Class<T> enumType) {
+    public <T extends Enum<?>> EnumEncodedValue<T> getEnumEncodedValue(String key, Class<T> enumType) {
         return encodedValueLookup.getEnumEncodedValue(key, enumType);
     }
+    
+    @Override
+    public StringEncodedValue getStringEncodedValue(String key) {
+        return encodedValueLookup.getStringEncodedValue(key);
+    }
 
     public void setEncodedValueLookup(EncodedValueLookup encodedValueLookup) {
         this.encodedValueLookup = encodedValueLookup;
diff --git a/core/src/main/java/com/graphhopper/routing/util/EncodingManager.java b/core/src/main/java/com/graphhopper/routing/util/EncodingManager.java
index dbb5b7fcf..b1a7cfbcf 100644
--- a/core/src/main/java/com/graphhopper/routing/util/EncodingManager.java
+++ b/core/src/main/java/com/graphhopper/routing/util/EncodingManager.java
@@ -772,10 +772,15 @@ public class EncodingManager implements EncodedValueLookup {
         return getEncodedValue(key, DecimalEncodedValue.class);
     }
 
-    @Override
     @SuppressWarnings("unchecked")
-    public <T extends Enum> EnumEncodedValue<T> getEnumEncodedValue(String key, Class<T> type) {
-        return (EnumEncodedValue<T>) getEncodedValue(key, EnumEncodedValue.class);
+    @Override
+    public <T extends Enum<?>> EnumEncodedValue<T> getEnumEncodedValue(String key, Class<T> type) {
+        return getEncodedValue(key, EnumEncodedValue.class);
+    }
+
+    @Override
+    public StringEncodedValue getStringEncodedValue(String key) {
+        return getEncodedValue(key, StringEncodedValue.class);
     }
 
     @Override
diff --git a/core/src/main/java/com/graphhopper/storage/BaseGraph.java b/core/src/main/java/com/graphhopper/storage/BaseGraph.java
index b4b86fe3c..ec45ef0da 100644
--- a/core/src/main/java/com/graphhopper/storage/BaseGraph.java
+++ b/core/src/main/java/com/graphhopper/storage/BaseGraph.java
@@ -21,6 +21,7 @@ import com.graphhopper.routing.ev.BooleanEncodedValue;
 import com.graphhopper.routing.ev.DecimalEncodedValue;
 import com.graphhopper.routing.ev.EnumEncodedValue;
 import com.graphhopper.routing.ev.IntEncodedValue;
+import com.graphhopper.routing.ev.StringEncodedValue;
 import com.graphhopper.routing.util.AllEdgesIterator;
 import com.graphhopper.routing.util.EdgeFilter;
 import com.graphhopper.routing.util.EncodingManager;
@@ -1209,31 +1210,31 @@ class BaseGraph implements Graph {
         }
 
         @Override
-        public <T extends Enum> T get(EnumEncodedValue<T> property) {
+        public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
             return property.getEnum(reverse, getFlags());
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+        public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
             property.setEnum(reverse, getFlags(), value);
             baseGraph.writeFlags(edgePointer, getFlags());
             return this;
         }
 
         @Override
-        public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+        public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
             return property.getEnum(!reverse, getFlags());
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+        public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
             property.setEnum(!reverse, getFlags(), value);
             baseGraph.writeFlags(edgePointer, getFlags());
             return this;
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+        public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
             if (!property.isStoreTwoDirections())
                 throw new IllegalArgumentException("EncodedValue " + property.getName() + " supports only one direction");
             property.setEnum(reverse, getFlags(), fwd);
@@ -1241,6 +1242,40 @@ class BaseGraph implements Graph {
             baseGraph.writeFlags(edgePointer, getFlags());
             return this;
         }
+        
+        @Override
+        public String get(StringEncodedValue property) {
+            return property.getString(reverse, getFlags());
+        }
+        
+        @Override
+        public EdgeIteratorState set(StringEncodedValue property, String value) {
+            property.setString(reverse, getFlags(), value);
+            baseGraph.writeFlags(edgePointer, getFlags());
+            return this;
+        }
+        
+        @Override
+        public String getReverse(StringEncodedValue property) {
+            return property.getString(!reverse, getFlags());
+        }
+        
+        @Override
+        public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+            property.setString(!reverse, getFlags(), value);
+            baseGraph.writeFlags(edgePointer, getFlags());
+            return this;
+        }
+        
+        @Override
+        public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
+            if (!property.isStoreTwoDirections())
+                throw new IllegalArgumentException("EncodedValue " + property.getName() + " supports only one direction");
+            property.setString(reverse, getFlags(), fwd);
+            property.setString(!reverse, getFlags(), bwd);
+            baseGraph.writeFlags(edgePointer, getFlags());
+            return this;
+        }
 
         @Override
         public final EdgeIteratorState copyPropertiesFrom(EdgeIteratorState edge) {
diff --git a/core/src/main/java/com/graphhopper/storage/CHGraphImpl.java b/core/src/main/java/com/graphhopper/storage/CHGraphImpl.java
index 066cf770e..4a7a154fb 100644
--- a/core/src/main/java/com/graphhopper/storage/CHGraphImpl.java
+++ b/core/src/main/java/com/graphhopper/storage/CHGraphImpl.java
@@ -23,6 +23,7 @@ import com.graphhopper.routing.ev.BooleanEncodedValue;
 import com.graphhopper.routing.ev.DecimalEncodedValue;
 import com.graphhopper.routing.ev.EnumEncodedValue;
 import com.graphhopper.routing.ev.IntEncodedValue;
+import com.graphhopper.routing.ev.StringEncodedValue;
 import com.graphhopper.routing.util.AllCHEdgesIterator;
 import com.graphhopper.routing.util.EdgeFilter;
 import com.graphhopper.storage.BaseGraph.AllEdgeIterator;
@@ -907,34 +908,64 @@ public class CHGraphImpl implements CHGraph, Storable<CHGraph> {
         }
 
         @Override
-        public <T extends Enum> T get(EnumEncodedValue<T> property) {
+        public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
             checkShortcut(false, "get(EnumEncodedValue<T>)");
             return edgeIterable.get(property);
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+        public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
             checkShortcut(false, "set(EnumEncodedValue<T>, T)");
             return edgeIterable.set(property, value);
         }
 
         @Override
-        public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+        public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
             checkShortcut(false, "getReverse(EnumEncodedValue<T>)");
             return edgeIterable.getReverse(property);
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+        public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
             checkShortcut(false, "setReverse(EnumEncodedValue<T>, T)");
             return edgeIterable.setReverse(property, value);
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+        public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
             checkShortcut(false, "set(EnumEncodedValue<T>, T, T)");
             return edgeIterable.set(property, fwd, bwd);
         }
+        
+        @Override
+        public String get(StringEncodedValue property) {
+            checkShortcut(false, "get(StringEncodedValue)");
+            return edgeIterable.get(property);
+        }
+        
+        @Override
+        public EdgeIteratorState set(StringEncodedValue property, String value) {
+            checkShortcut(false, "set(StringEncodedValue, String)");
+            return edgeIterable.set(property, value);
+        }
+        
+        @Override
+        public String getReverse(StringEncodedValue property) {
+            checkShortcut(false, "getReverse(StringEncodedValue)");
+            return edgeIterable.getReverse(property);
+        }
+        
+        @Override
+        public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+            checkShortcut(false, "setReverse(StringEncodedValue, String)");
+            return edgeIterable.setReverse(property, value);
+        }
+        
+        @Override
+        public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
+            checkShortcut(false, "set(StringEncodedValue, String, String)");
+            return edgeIterable.set(property, fwd, bwd);
+        }
 
         @Override
         public EdgeIteratorState detach(boolean reverseArg) {
diff --git a/core/src/main/java/com/graphhopper/util/EdgeIteratorState.java b/core/src/main/java/com/graphhopper/util/EdgeIteratorState.java
index bcab8d4a8..9038ed960 100644
--- a/core/src/main/java/com/graphhopper/util/EdgeIteratorState.java
+++ b/core/src/main/java/com/graphhopper/util/EdgeIteratorState.java
@@ -182,15 +182,25 @@ public interface EdgeIteratorState {
 
     EdgeIteratorState set(DecimalEncodedValue property, double fwd, double bwd);
 
-    <T extends Enum> T get(EnumEncodedValue<T> property);
+    <T extends Enum<?>> T get(EnumEncodedValue<T> property);
 
-    <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value);
+    <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value);
 
-    <T extends Enum> T getReverse(EnumEncodedValue<T> property);
+    <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property);
 
-    <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value);
+    <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value);
 
-    <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd);
+    <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd);
+    
+    String get(StringEncodedValue property);
+    
+    EdgeIteratorState set(StringEncodedValue property, String value);
+    
+    String getReverse(StringEncodedValue property);
+    
+    EdgeIteratorState setReverse(StringEncodedValue property, String value);
+    
+    EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd);
 
     String getName();
 
diff --git a/core/src/main/java/com/graphhopper/util/GHUtility.java b/core/src/main/java/com/graphhopper/util/GHUtility.java
index cb42da17a..118c3df4d 100644
--- a/core/src/main/java/com/graphhopper/util/GHUtility.java
+++ b/core/src/main/java/com/graphhopper/util/GHUtility.java
@@ -523,12 +523,12 @@ public class GHUtility {
             }
 
             @Override
-            public <T extends Enum> T get(EnumEncodedValue<T> property) {
+            public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
                 return property.getEnum(false, flags);
             }
 
             @Override
-            public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+            public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
                 return property.getEnum(true, flags);
             }
 
@@ -887,27 +887,52 @@ public class GHUtility {
         }
 
         @Override
-        public <T extends Enum> T get(EnumEncodedValue<T> property) {
+        public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
             throw new UnsupportedOperationException("Not supported. Edge is empty.");
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+        public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
             throw new UnsupportedOperationException("Not supported. Edge is empty.");
         }
 
         @Override
-        public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+        public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
             throw new UnsupportedOperationException("Not supported. Edge is empty.");
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+        public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
             throw new UnsupportedOperationException("Not supported. Edge is empty.");
         }
 
         @Override
-        public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+        public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+            throw new UnsupportedOperationException("Not supported. Edge is empty.");
+        }
+        
+        @Override
+        public String get(StringEncodedValue property) {
+            throw new UnsupportedOperationException("Not supported. Edge is empty.");
+        }
+        
+        @Override
+        public EdgeIteratorState set(StringEncodedValue property, String value) {
+            throw new UnsupportedOperationException("Not supported. Edge is empty.");
+        }
+        
+        @Override
+        public String getReverse(StringEncodedValue property) {
+            throw new UnsupportedOperationException("Not supported. Edge is empty.");
+        }
+        
+        @Override
+        public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+            throw new UnsupportedOperationException("Not supported. Edge is empty.");
+        }
+        
+        @Override
+        public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
             throw new UnsupportedOperationException("Not supported. Edge is empty.");
         }
 
diff --git a/core/src/main/java/com/graphhopper/util/details/StringDetails.java b/core/src/main/java/com/graphhopper/util/details/StringDetails.java
new file mode 100644
index 000000000..bdeabceac
--- /dev/null
+++ b/core/src/main/java/com/graphhopper/util/details/StringDetails.java
@@ -0,0 +1,48 @@
+/*
+ *  Licensed to GraphHopper GmbH under one or more contributor
+ *  license agreements. See the NOTICE file distributed with this work for
+ *  additional information regarding copyright ownership.
+ *
+ *  GraphHopper GmbH licenses this file to you under the Apache License,
+ *  Version 2.0 (the "License"); you may not use this file except in
+ *  compliance with the License. You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package com.graphhopper.util.details;
+
+import com.graphhopper.routing.ev.StringEncodedValue;
+import com.graphhopper.util.EdgeIteratorState;
+
+public class StringDetails extends AbstractPathDetailsBuilder {
+
+    private final StringEncodedValue ev;
+    private String currentVal = null;
+
+    public StringDetails(String name, StringEncodedValue ev) {
+        super(name);
+        this.ev = ev;
+    }
+
+    @Override
+    protected Object getCurrentValue() {
+        return currentVal;
+    }
+
+    @Override
+    public boolean isEdgeDifferentToLastEdge(EdgeIteratorState edge) {
+        String val = edge.get(ev);
+        // we can use the reference equality here
+        if (val != currentVal) {
+            this.currentVal = val;
+            return true;
+        }
+        return false;
+    }
+}
diff --git a/hmm-lib/README.md b/hmm-lib/README.md
index 24855626e..a84d9cce0 100644
--- a/hmm-lib/README.md
+++ b/hmm-lib/README.md
@@ -15,8 +15,8 @@ This library was initially created for HMM-based map matching according to the p
 In: Proceedings of the 17th ACM SIGSPATIAL international conference on advances in geographic
 information systems. ACM, 2009. S. 336-343."
 
-[Graphhopper](https://graphhopper.com/) [map matching](https://github.com/graphhopper/map-matching)
-is now using the hmm-lib for matching GPS positions to OpenStreetMap maps. 
+[Graphhopper](https://graphhopper.com/) [map matching](https://github.com/graphhopper/graphhopper/tree/master/map-matching)
+is using the hmm-lib for matching GPS positions to the OpenStreetMap road network.
 
 The [offline-map-matching](https://github.com/bmwcarit/offline-map-matching) project
 demonstrates how to use the hmm-lib for map matching but does not provide integration to any
diff --git a/map-matching/README.md b/map-matching/README.md
index c725d19f0..b427b5798 100644
--- a/map-matching/README.md
+++ b/map-matching/README.md
@@ -13,7 +13,7 @@ See the demo in action (black is GPS track, green is matched result):
 
 Start via:
 ```bash
-java -jar matching-web/target/graphhopper-web-3.0-SNAPSHOT.jar server config.yml
+java -jar web-bundle/target/graphhopper-web-3.0-SNAPSHOT.jar server config.yml
 ```
 
 Access the simple UI via `http://localhost:8989/maps/map-matching/` (including the trailing slash).
@@ -27,7 +27,7 @@ curl -XPOST -H "Content-Type: application/gpx+xml" -d @web/src/test/resources/te
 
 Determine the bounding box of one or more GPX files:
 ```bash
-java -jar matching-web/target/graphhopper-web-3.0-SNAPSHOT.jar getbounds web/src/test/resources/*.gpx
+java -jar web-bundle/target/graphhopper-web-3.0-SNAPSHOT.jar getbounds web/src/test/resources/*.gpx
 ```
 
 #### Java usage
@@ -64,7 +64,4 @@ The [Viterbi algorithm](https://en.wikipedia.org/wiki/Viterbi_algorithm) as prov
 of map matching candidates. Thereby, the distances between GPS positions and map matching
 candidates as well as the routing distances between consecutive map matching candidates are taken
 into account. The GraphHopper routing engine is used to find candidates and to compute routing
-distances.
-
-Before GraphHopper 0.8, [this faster but more heuristic approach](https://karussell.wordpress.com/2014/07/28/digitalizing-gpx-points-or-how-to-track-vehicles-with-graphhopper/)
-was used.
+distances.
\ No newline at end of file
diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/WrapperGraph.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/WrapperGraph.java
index f6c8f5639..beeb7c732 100644
--- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/WrapperGraph.java
+++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/WrapperGraph.java
@@ -25,6 +25,7 @@ import com.graphhopper.routing.ev.BooleanEncodedValue;
 import com.graphhopper.routing.ev.DecimalEncodedValue;
 import com.graphhopper.routing.ev.EnumEncodedValue;
 import com.graphhopper.routing.ev.IntEncodedValue;
+import com.graphhopper.routing.ev.StringEncodedValue;
 import com.graphhopper.routing.querygraph.VirtualEdgeIteratorState;
 import com.graphhopper.routing.util.AllEdgesIterator;
 import com.graphhopper.routing.util.EdgeFilter;
@@ -264,27 +265,52 @@ public class WrapperGraph implements Graph {
             }
 
             @Override
-            public <T extends Enum> T get(EnumEncodedValue<T> property) {
+            public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
                 throw new UnsupportedOperationException();
             }
 
             @Override
-            public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+            public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
                 throw new UnsupportedOperationException();
             }
 
             @Override
-            public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+            public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
                 throw new UnsupportedOperationException();
             }
 
             @Override
-            public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+            public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
                 throw new UnsupportedOperationException();
             }
 
             @Override
-            public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+            public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+                throw new UnsupportedOperationException();
+            }
+            
+            @Override
+            public String get(StringEncodedValue property) {
+                throw new UnsupportedOperationException();
+            }
+            
+            @Override
+            public EdgeIteratorState set(StringEncodedValue property, String value) {
+                throw new UnsupportedOperationException();
+            }
+            
+            @Override
+            public String getReverse(StringEncodedValue property) {
+                throw new UnsupportedOperationException();
+            }
+            
+            @Override
+            public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+                throw new UnsupportedOperationException();
+            }
+            
+            @Override
+            public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
                 throw new UnsupportedOperationException();
             }
 
@@ -500,29 +526,57 @@ public class WrapperGraph implements Graph {
                     }
 
                     @Override
-                    public <T extends Enum> T get(EnumEncodedValue<T> property) {
+                    public <T extends Enum<?>> T get(EnumEncodedValue<T> property) {
                         return current.get(property);
                     }
 
                     @Override
-                    public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
+                    public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T value) {
                         current.set(property, value);
                         return this;
                     }
 
                     @Override
-                    public <T extends Enum> T getReverse(EnumEncodedValue<T> property) {
+                    public <T extends Enum<?>> T getReverse(EnumEncodedValue<T> property) {
                         return current.getReverse(property);
                     }
 
                     @Override
-                    public <T extends Enum> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
+                    public <T extends Enum<?>> EdgeIteratorState setReverse(EnumEncodedValue<T> property, T value) {
                         current.setReverse(property, value);
                         return this;
                     }
 
                     @Override
-                    public <T extends Enum> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+                    public <T extends Enum<?>> EdgeIteratorState set(EnumEncodedValue<T> property, T fwd, T bwd) {
+                        current.set(property, fwd, bwd);
+                        return this;
+                    }
+                    
+                    @Override
+                    public String get(StringEncodedValue property) {
+                        return current.get(property);
+                    }
+                    
+                    @Override
+                    public EdgeIteratorState set(StringEncodedValue property, String value) {
+                        current.set(property, value);
+                        return this;
+                    }
+                    
+                    @Override
+                    public String getReverse(StringEncodedValue property) {
+                        return current.getReverse(property);
+                    }
+                    
+                    @Override
+                    public EdgeIteratorState setReverse(StringEncodedValue property, String value) {
+                        current.setReverse(property, value);
+                        return this;
+                    }
+                    
+                    @Override
+                    public EdgeIteratorState set(StringEncodedValue property, String fwd, String bwd) {
                         current.set(property, fwd, bwd);
                         return this;
                     }
diff --git a/web-bundle/src/main/java/com/graphhopper/http/MultiExceptionMapper.java b/web-bundle/src/main/java/com/graphhopper/http/MultiExceptionMapper.java
index 123e903e2..6904a5783 100644
--- a/web-bundle/src/main/java/com/graphhopper/http/MultiExceptionMapper.java
+++ b/web-bundle/src/main/java/com/graphhopper/http/MultiExceptionMapper.java
@@ -33,7 +33,9 @@ public class MultiExceptionMapper implements ExceptionMapper<MultiException> {
 
     @Override
     public Response toResponse(MultiException e) {
-        logger.info("bad request: " + (Helper.isEmpty(e.getMessage()) ? "unknown reason" : e.getErrors()));
+        logger.info("bad request: " + (Helper.isEmpty(e.getMessage())
+                ? (e.getErrors().isEmpty() ? "unknown reason" : e.getErrors().toString())
+                : e.getErrors()));
         return Response.status(Response.Status.BAD_REQUEST)
                 .entity(e)
                 .build();


Test patch:
diff --git a/core/src/test/java/com/graphhopper/routing/DirectedRoutingTest.java b/core/src/test/java/com/graphhopper/routing/DirectedRoutingTest.java
index 5c378e58f..d13df075b 100644
--- a/core/src/test/java/com/graphhopper/routing/DirectedRoutingTest.java
+++ b/core/src/test/java/com/graphhopper/routing/DirectedRoutingTest.java
@@ -42,6 +42,8 @@ import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.*;
 
@@ -64,6 +66,7 @@ import static org.junit.Assert.fail;
  */
 @RunWith(Parameterized.class)
 public class DirectedRoutingTest {
+    private static final Logger LOGGER = LoggerFactory.getLogger(DirectedRoutingTest.class);
     private final Algo algo;
     private final int uTurnCosts;
     private final boolean prepareCH;
@@ -192,7 +195,7 @@ public class DirectedRoutingTest {
             int target = getRandom(rnd);
             int sourceOutEdge = getSourceOutEdge(rnd, source, graph);
             int targetInEdge = getTargetInEdge(rnd, target, graph);
-//            System.out.println("source: " + source + ", target: " + target + ", sourceOutEdge: " + sourceOutEdge + ", targetInEdge: " + targetInEdge);
+//            LOGGER.info("source: " + source + ", target: " + target + ", sourceOutEdge: " + sourceOutEdge + ", targetInEdge: " + targetInEdge);
             Path refPath = new DijkstraBidirectionRef(graph, ((Graph) graph).wrapWeighting(weighting), TraversalMode.EDGE_BASED)
                     .calcPath(source, target, sourceOutEdge, targetInEdge);
             Path path = createAlgo()
@@ -204,7 +207,7 @@ public class DirectedRoutingTest {
         // is wrong and we fail
         if (strictViolations.size() > Math.max(1, 0.05 * numQueries)) {
             for (String strictViolation : strictViolations) {
-                System.out.println("strict violation: " + strictViolation);
+                LOGGER.info("strict violation: " + strictViolation);
             }
             fail("Too many strict violations, with seed: " + seed + " - " + strictViolations.size() + " / " + numQueries);
         }
@@ -264,8 +267,8 @@ public class DirectedRoutingTest {
         double refWeight = refPath.getWeight();
         double weight = path.getWeight();
         if (Math.abs(refWeight - weight) > 1.e-2) {
-            System.out.println("expected: " + refPath.calcNodes());
-            System.out.println("given:    " + path.calcNodes());
+            LOGGER.warn("expected: " + refPath.calcNodes());
+            LOGGER.warn("given:    " + path.calcNodes());
             fail("wrong weight: " + source + "->" + target + ", expected: " + refWeight + ", given: " + weight + ", seed: " + seed);
         }
         if (Math.abs(path.getDistance() - refPath.getDistance()) > 1.e-1) {
diff --git a/core/src/test/java/com/graphhopper/routing/EdgeBasedRoutingAlgorithmTest.java b/core/src/test/java/com/graphhopper/routing/EdgeBasedRoutingAlgorithmTest.java
index 86cf016f3..79665253a 100644
--- a/core/src/test/java/com/graphhopper/routing/EdgeBasedRoutingAlgorithmTest.java
+++ b/core/src/test/java/com/graphhopper/routing/EdgeBasedRoutingAlgorithmTest.java
@@ -38,6 +38,8 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.junit.runners.Parameterized.Parameters;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.*;
 
@@ -51,6 +53,7 @@ import static org.junit.Assert.*;
  */
 @RunWith(Parameterized.class)
 public class EdgeBasedRoutingAlgorithmTest {
+    private static final Logger LOGGER = LoggerFactory.getLogger(EdgeBasedRoutingAlgorithmTest.class);
     private final String algoStr;
     private FlagEncoder carEncoder;
     private TurnCostStorage tcs;
@@ -180,8 +183,8 @@ public class EdgeBasedRoutingAlgorithmTest {
 
             double weight = path.getWeight();
             if (Math.abs(refWeight - weight) > 1.e-2) {
-                System.out.println("expected: " + refPath.calcNodes());
-                System.out.println("given:    " + path.calcNodes());
+                LOGGER.warn("expected: " + refPath.calcNodes());
+                LOGGER.warn("given:    " + path.calcNodes());
                 fail("wrong weight: " + from + "->" + to + ", dijkstra: " + refWeight + " vs. " + algoStr + ": " + path.getWeight());
             }
             if (Math.abs(path.getDistance() - refPath.getDistance()) > 1.e-1) {
diff --git a/core/src/test/java/com/graphhopper/routing/RandomCHRoutingTest.java b/core/src/test/java/com/graphhopper/routing/RandomCHRoutingTest.java
index 42fb85ff2..2af461caf 100644
--- a/core/src/test/java/com/graphhopper/routing/RandomCHRoutingTest.java
+++ b/core/src/test/java/com/graphhopper/routing/RandomCHRoutingTest.java
@@ -22,6 +22,8 @@ import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.*;
 
@@ -32,6 +34,7 @@ import static org.junit.Assert.fail;
 
 @RunWith(Parameterized.class)
 public class RandomCHRoutingTest {
+    private static final Logger LOGGER = LoggerFactory.getLogger(RandomCHRoutingTest.class);
     private final TraversalMode traversalMode;
     private final int maxTurnCosts;
     private final int uTurnCosts;
@@ -80,7 +83,7 @@ public class RandomCHRoutingTest {
         // bugs (e.g. use intellij 'run until stop/failure').
         int numNodes = 50;
         long seed = System.nanoTime();
-        System.out.println("seed: " + seed);
+        LOGGER.info("seed: " + seed);
         Random rnd = new Random(seed);
         // we may not use an offset when query graph is involved, otherwise traveling via virtual edges will not be
         // the same as taking the direct edge!
@@ -178,8 +181,8 @@ public class RandomCHRoutingTest {
 
                 double weight = path.getWeight();
                 if (Math.abs(refWeight - weight) > 1.e-2) {
-                    System.out.println("expected: " + refPath.calcNodes());
-                    System.out.println("given:    " + path.calcNodes());
+                    LOGGER.warn("expected: " + refPath.calcNodes());
+                    LOGGER.warn("given:    " + path.calcNodes());
                     fail("wrong weight: " + from + "->" + to + ", dijkstra: " + refWeight + " vs. ch: " + path.getWeight());
                 }
                 if (Math.abs(path.getDistance() - refPath.getDistance()) > 1.e-1) {
diff --git a/core/src/test/java/com/graphhopper/routing/RandomizedRoutingTest.java b/core/src/test/java/com/graphhopper/routing/RandomizedRoutingTest.java
index fae2c456a..40c5f9ebb 100644
--- a/core/src/test/java/com/graphhopper/routing/RandomizedRoutingTest.java
+++ b/core/src/test/java/com/graphhopper/routing/RandomizedRoutingTest.java
@@ -18,6 +18,7 @@
 
 package com.graphhopper.routing;
 
+import com.carrotsearch.hppc.IntArrayList;
 import com.carrotsearch.hppc.IntIndexedContainer;
 import com.graphhopper.Repeat;
 import com.graphhopper.RepeatRule;
@@ -34,6 +35,7 @@ import com.graphhopper.storage.*;
 import com.graphhopper.storage.index.LocationIndexTree;
 import com.graphhopper.storage.index.Snap;
 import com.graphhopper.util.ArrayUtil;
+import com.graphhopper.util.EdgeIteratorState;
 import com.graphhopper.util.GHUtility;
 import com.graphhopper.util.PMap;
 import org.junit.Before;
@@ -41,6 +43,8 @@ import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.*;
 
@@ -61,6 +65,7 @@ import static org.junit.Assert.fail;
  */
 @RunWith(Parameterized.class)
 public class RandomizedRoutingTest {
+    private static final Logger LOGGER = LoggerFactory.getLogger(RandomizedRoutingTest.class);
     private final Algo algo;
     private final boolean prepareCH;
     private final boolean prepareLM;
@@ -206,7 +211,7 @@ public class RandomizedRoutingTest {
         for (int i = 0; i < numQueries; i++) {
             int source = getRandom(rnd);
             int target = getRandom(rnd);
-//            System.out.println("source: " + source + ", target: " + target);
+//            LOGGER.info("source: " + source + ", target: " + target);
             Path refPath = new DijkstraBidirectionRef(graph, weighting, traversalMode)
                     .calcPath(source, target);
             Path path = createAlgo()
@@ -215,7 +220,7 @@ public class RandomizedRoutingTest {
         }
         if (strictViolations.size() > 3) {
             for (String strictViolation : strictViolations) {
-                System.out.println("strict violation: " + strictViolation);
+                LOGGER.info("strict violation: " + strictViolation);
             }
             fail("Too many strict violations: " + strictViolations.size() + " / " + numQueries + ", seed: " + seed);
         }
@@ -256,7 +261,7 @@ public class RandomizedRoutingTest {
         // we do not do a strict check because there can be ambiguity, for example when there are zero weight loops.
         // however, when there are too many deviations we fail
         if (strictViolations.size() > 3) {
-            System.out.println(strictViolations);
+            LOGGER.warn(strictViolations.toString());
             fail("Too many strict violations: " + strictViolations.size() + " / " + numQueries + ", seed: " + seed);
         }
     }
@@ -266,9 +271,9 @@ public class RandomizedRoutingTest {
         double refWeight = refPath.getWeight();
         double weight = path.getWeight();
         if (Math.abs(refWeight - weight) > 1.e-2) {
-            System.out.println("expected: " + refPath.calcNodes());
-            System.out.println("given:    " + path.calcNodes());
-            System.out.println("seed: " + seed);
+            LOGGER.warn("expected: " + refPath.calcNodes());
+            LOGGER.warn("given:    " + path.calcNodes());
+            LOGGER.warn("seed: " + seed);
             fail("wrong weight: " + source + "->" + target + "\nexpected: " + refWeight + "\ngiven:    " + weight + "\nseed: " + seed);
         }
         if (Math.abs(path.getDistance() - refPath.getDistance()) > 1.e-1) {
@@ -282,9 +287,13 @@ public class RandomizedRoutingTest {
         if (!refNodes.equals(pathNodes)) {
             // sometimes paths are only different because of a zero weight loop. we do not consider these as strict
             // violations, see: #1864
-            if (!ArrayUtil.withoutConsecutiveDuplicates(refNodes).equals(ArrayUtil.withoutConsecutiveDuplicates(pathNodes))) {
+            boolean isStrictViolation = !ArrayUtil.withoutConsecutiveDuplicates(refNodes).equals(ArrayUtil.withoutConsecutiveDuplicates(pathNodes));
+            // sometimes there are paths including an edge a-c that has the same distance as the two edges a-b-c. in this
+            // case both options are valid best paths. we only check for this most simple and frequent case here...
+            if (pathsEqualExceptOneEdge(refNodes, pathNodes))
+                isStrictViolation = false;
+            if (isStrictViolation)
                 strictViolations.add("wrong nodes " + source + "->" + target + "\nexpected: " + refNodes + "\ngiven:    " + pathNodes);
-            }
         }
         return strictViolations;
     }
@@ -293,4 +302,52 @@ public class RandomizedRoutingTest {
         return rnd.nextInt(graph.getNodes());
     }
 
+    /**
+     * Sometimes the graph can contain edges like this:
+     * A--C
+     * \-B|
+     * where A-C is the same distance as A-B-C. In this case the shortest path is not well defined in terms of nodes.
+     * This method check if two node-paths are equal with the exception of such an edge.
+     */
+    private boolean pathsEqualExceptOneEdge(IntIndexedContainer p1, IntIndexedContainer p2) {
+        if (p1.equals(p2))
+            throw new IllegalArgumentException("paths are equal");
+        if (Math.abs(p1.size() - p2.size()) != 1)
+            return false;
+        IntIndexedContainer shorterPath = p1.size() < p2.size() ? p1 : p2;
+        IntIndexedContainer longerPath = p1.size() < p2.size() ? p2 : p1;
+        if (shorterPath.size() < 2)
+            return false;
+        IntArrayList indicesWithDifferentNodes = new IntArrayList();
+        for (int i = 1; i < shorterPath.size(); i++) {
+            if (shorterPath.get(i - indicesWithDifferentNodes.size()) != longerPath.get(i)) {
+                indicesWithDifferentNodes.add(i);
+            }
+        }
+        if (indicesWithDifferentNodes.size() != 1)
+            return false;
+        int b = indicesWithDifferentNodes.get(0);
+        int a = b - 1;
+        int c = b + 1;
+        assert shorterPath.get(a) == longerPath.get(a);
+        assert shorterPath.get(b) != longerPath.get(b);
+        if (shorterPath.get(b) != longerPath.get(c))
+            return false;
+        double distABC = getDist(longerPath.get(a), longerPath.get(b)) + getDist(longerPath.get(b), longerPath.get(c));
+
+        double distAC = getDist(shorterPath.get(a), longerPath.get(c));
+        if (Math.abs(distABC - distAC) > 0.1)
+            return false;
+        LOGGER.info("Distance " + shorterPath.get(a) + "-" + longerPath.get(c) + " is the same as distance " +
+                longerPath.get(a) + "-" + longerPath.get(b) + "-" + longerPath.get(c) + " -> there are multiple possibilities " +
+                "for shortest paths");
+        return true;
+    }
+
+    private double getDist(int p, int q) {
+        EdgeIteratorState edge = GHUtility.getEdge(graph, p, q);
+        if (edge == null) return Double.POSITIVE_INFINITY;
+        return edge.getDistance();
+    }
+
 }
diff --git a/core/src/test/java/com/graphhopper/routing/ch/CHTurnCostTest.java b/core/src/test/java/com/graphhopper/routing/ch/CHTurnCostTest.java
index 776527e81..e950ebb5a 100644
--- a/core/src/test/java/com/graphhopper/routing/ch/CHTurnCostTest.java
+++ b/core/src/test/java/com/graphhopper/routing/ch/CHTurnCostTest.java
@@ -22,6 +22,8 @@ import com.graphhopper.routing.Dijkstra;
 import com.graphhopper.routing.DijkstraBidirectionEdgeCHNoSOD;
 import com.graphhopper.routing.Path;
 import com.graphhopper.routing.RoutingAlgorithm;
+import com.graphhopper.routing.ev.BooleanEncodedValue;
+import com.graphhopper.routing.ev.DecimalEncodedValue;
 import com.graphhopper.routing.ev.EncodedValueLookup;
 import com.graphhopper.routing.ev.TurnCost;
 import com.graphhopper.routing.querygraph.QueryGraph;
@@ -51,7 +53,7 @@ import static com.graphhopper.util.GHUtility.updateDistancesFor;
 import static com.graphhopper.util.Parameters.Algorithms.ASTAR_BI;
 import static com.graphhopper.util.Parameters.Algorithms.DIJKSTRA_BI;
 import static com.graphhopper.util.Parameters.Routing.ALGORITHM;
-import static org.junit.Assert.*;
+import static org.junit.jupiter.api.Assertions.*;
 
 /**
  * Here we test if Contraction Hierarchies work with turn costs, i.e. we first contract the graph and then run
@@ -905,7 +907,7 @@ public class CHTurnCostTest {
         // travel via the virtual node 7 to node 1. From there we cannot go to 6 because of the one-way so we go back
         // to node 2 (no u-turn because of the duplicate edge) on edge1. And this is were the journey ends: we cannot
         // go to 8 because of the turn restriction from edge1 to edge4 -> there should not be a path!
-        assertFalse("there should not be a path, but found: " + path.calcNodes(), path.isFound());
+        assertFalse(path.isFound(), "there should not be a path, but found: " + path.calcNodes());
     }
 
     @ParameterizedTest
@@ -933,7 +935,7 @@ public class CHTurnCostTest {
         graph.freeze();
         prepareCH(0, 1, 2, 3, 4, 5);
         assertEquals(5, chGraph.getBaseGraph().getEdges());
-        assertEquals("expected two shortcuts: 3->5 and 5->3", 7, chGraph.getEdges());
+        assertEquals(7, chGraph.getEdges(), "expected two shortcuts: 3->5 and 5->3");
         // there should be no path from 2 to 1, because of the turn restriction and because u-turns are not allowed
         assertFalse(findPathUsingDijkstra(2, 1).isFound());
         compareCHQueryWithDijkstra(2, 1);
@@ -944,11 +946,11 @@ public class CHTurnCostTest {
         index.prepareIndex();
         Snap snap = index.findClosest(0.1, 0.15, EdgeFilter.ALL_EDGES);
         QueryGraph queryGraph = QueryGraph.create(graph, snap);
-        assertEquals("expected one virtual node", 1, queryGraph.getNodes() - chGraph.getNodes());
+        assertEquals(1, queryGraph.getNodes() - chGraph.getNodes(), "expected one virtual node");
         QueryRoutingCHGraph routingCHGraph = new QueryRoutingCHGraph(chGraph, queryGraph);
         RoutingAlgorithm chAlgo = new CHRoutingAlgorithmFactory(routingCHGraph).createAlgo(new PMap().putObject(ALGORITHM, algo));
         Path path = chAlgo.calcPath(2, 1);
-        assertFalse("no path should be found, but found " + path.calcNodes(), path.isFound());
+        assertFalse(path.isFound(), "no path should be found, but found " + path.calcNodes());
     }
 
     @ParameterizedTest
@@ -971,7 +973,7 @@ public class CHTurnCostTest {
         assertEquals(0, snap.getClosestEdge().getEdge());
         RoutingAlgorithm chAlgo = new CHRoutingAlgorithmFactory(chGraph, queryGraph).createAlgo(new PMap().putObject(ALGORITHM, algo));
         Path path = chAlgo.calcPath(0, 2);
-        assertTrue("it should be possible to route via a virtual node, but no path found", path.isFound());
+        assertTrue(path.isFound(), "it should be possible to route via a virtual node, but no path found");
         assertEquals(IntArrayList.from(0, 3, 1, 2), path.calcNodes());
         assertEquals(DistancePlaneProjection.DIST_PLANE.calcDist(0.00, 0.00, 0.03, 0.03), path.getDistance(), 1.e-1);
     }
@@ -1053,6 +1055,41 @@ public class CHTurnCostTest {
         assertEquals(dijkstraPath.getTime(), path.getTime());
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {DIJKSTRA_BI, ASTAR_BI})
+    public void test_astar_issue2061(String algo) {
+        // here the direct path 0-2-3-4-5 is clearly the shortest, however there was a bug in the a-star(-like)
+        // algo: first the non-optimal path 0-1-5 is found, but before we find the actual shortest path we explore
+        // node 6 during the forward search. the path 0-6-x-5 cannot possibly be the shortest path because 0-6-5
+        // is already worse than 0-1-5, even if there was a beeline link from 6 to 5. the problem was that then we
+        // cancelled the entire fwd search instead of simply stalling node 6.
+        //       |-------1-|
+        // 7-6---0---2-3-4-5
+        BooleanEncodedValue accessEnc = encoder.getAccessEnc();
+        DecimalEncodedValue speedEnc = encoder.getAverageSpeedEnc();
+        graph.edge(0, 1).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(1, 5).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(0, 2).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(2, 3).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(3, 4).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(4, 5).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(0, 6).set(accessEnc, true).set(speedEnc, 60);
+        graph.edge(6, 7).set(accessEnc, true).set(speedEnc, 60);
+        updateDistancesFor(graph, 0, 46.5, 9.7);
+        updateDistancesFor(graph, 1, 46.9, 9.8);
+        updateDistancesFor(graph, 2, 46.7, 9.7);
+        updateDistancesFor(graph, 4, 46.9, 9.7);
+        updateDistancesFor(graph, 3, 46.8, 9.7);
+        updateDistancesFor(graph, 5, 47.0, 9.7);
+        updateDistancesFor(graph, 6, 46.3, 9.7);
+        updateDistancesFor(graph, 7, 46.2, 9.7);
+        graph.freeze();
+        automaticPrepareCH();
+        RoutingAlgorithm chAlgo = new CHRoutingAlgorithmFactory(chGraph).createAlgo(new PMap().putObject(ALGORITHM, algo));
+        Path path = chAlgo.calcPath(0, 5);
+        assertEquals(IntArrayList.from(0, 2, 3, 4, 5), path.calcNodes());
+    }
+
     /**
      * This test runs on a random graph with random turn costs and a predefined (but random) contraction order.
      * It often produces exotic conditions that are hard to anticipate beforehand.
@@ -1136,10 +1173,10 @@ public class CHTurnCostTest {
         int expectedWeight = expectedEdgeWeight + expectedTurnCosts;
         int expectedDistance = expectedEdgeWeight;
         int expectedTime = expectedEdgeWeight * 60 + expectedTurnCosts * 1000;
-        assertEquals("Normal Dijkstra did not find expected path.", expectedPath, dijkstraPath.calcNodes());
-        assertEquals("Normal Dijkstra did not calculate expected weight.", expectedWeight, dijkstraPath.getWeight(), 1.e-6);
-        assertEquals("Normal Dijkstra did not calculate expected distance.", expectedDistance, dijkstraPath.getDistance(), 1.e-6);
-        assertEquals("Normal Dijkstra did not calculate expected time.", expectedTime, dijkstraPath.getTime(), 1.e-6);
+        assertEquals(expectedPath, dijkstraPath.calcNodes(), "Normal Dijkstra did not find expected path.");
+        assertEquals(expectedWeight, dijkstraPath.getWeight(), 1.e-6, "Normal Dijkstra did not calculate expected weight.");
+        assertEquals(expectedDistance, dijkstraPath.getDistance(), 1.e-6, "Normal Dijkstra did not calculate expected distance.");
+        assertEquals(expectedTime, dijkstraPath.getTime(), 2, "Normal Dijkstra did not calculate expected time.");
     }
 
     private void checkPathUsingCH(IntArrayList expectedPath, int expectedEdgeWeight, int expectedTurnCosts, int from, int to, int[] contractionOrder) {
@@ -1147,10 +1184,10 @@ public class CHTurnCostTest {
         int expectedWeight = expectedEdgeWeight + expectedTurnCosts;
         int expectedDistance = expectedEdgeWeight;
         int expectedTime = expectedEdgeWeight * 60 + expectedTurnCosts * 1000;
-        assertEquals("Contraction Hierarchies did not find expected path. contraction order=" + contractionOrder, expectedPath, chPath.calcNodes());
-        assertEquals("Contraction Hierarchies did not calculate expected weight.", expectedWeight, chPath.getWeight(), 1.e-6);
-        assertEquals("Contraction Hierarchies did not calculate expected distance.", expectedDistance, chPath.getDistance(), 1.e-6);
-        assertEquals("Contraction Hierarchies did not calculate expected time.", expectedTime, chPath.getTime(), 1.e-6);
+        assertEquals(expectedPath, chPath.calcNodes(), "Contraction Hierarchies did not find expected path. contraction order=" + Arrays.toString(contractionOrder));
+        assertEquals(expectedWeight, chPath.getWeight(), 1.e-6, "Contraction Hierarchies did not calculate expected weight.");
+        assertEquals(expectedDistance, chPath.getDistance(), 1.e-6, "Contraction Hierarchies did not calculate expected distance.");
+        assertEquals(expectedTime, chPath.getTime(), 2, "Contraction Hierarchies did not calculate expected time.");
     }
 
     private Path findPathUsingDijkstra(int from, int to) {
diff --git a/core/src/test/java/com/graphhopper/routing/ev/StringEncodedValueTest.java b/core/src/test/java/com/graphhopper/routing/ev/StringEncodedValueTest.java
new file mode 100644
index 000000000..1c356e3a9
--- /dev/null
+++ b/core/src/test/java/com/graphhopper/routing/ev/StringEncodedValueTest.java
@@ -0,0 +1,131 @@
+package com.graphhopper.routing.ev;
+
+import com.graphhopper.storage.IntsRef;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class StringEncodedValueTest {
+
+    @Test
+    public void testInitExact() {
+        // 3+1 values -> 2 bits
+        StringEncodedValue prop = new StringEncodedValue("country", 3);
+        EncodedValue.InitializerConfig init = new EncodedValue.InitializerConfig();
+        assertEquals(2, prop.init(init));
+        assertEquals(2, prop.bits);
+        assertEquals(0, init.dataIndex);
+        assertEquals(0, init.shift);
+    }
+
+    @Test
+    public void testInitRoundUp() {
+        // 33+1 values -> 6 bits
+        StringEncodedValue prop = new StringEncodedValue("country", 33);
+        EncodedValue.InitializerConfig init = new EncodedValue.InitializerConfig();
+        assertEquals(6, prop.init(init));
+        assertEquals(6, prop.bits);
+        assertEquals(0, init.dataIndex);
+        assertEquals(0, init.shift);
+    }
+    
+    @Test
+    public void testInitSingle() {
+        StringEncodedValue prop = new StringEncodedValue("country", 1);
+        EncodedValue.InitializerConfig init = new EncodedValue.InitializerConfig();
+        assertEquals(1, prop.init(init));
+        assertEquals(1, prop.bits);
+        assertEquals(0, init.dataIndex);
+        assertEquals(0, init.shift);
+    }
+    
+    @Test
+    public void testInitTooManyEntries() {
+        List<String> values = Arrays.asList("aut", "deu", "che", "fra");
+        try {
+            new StringEncodedValue("country", 2, values, false);
+            fail("The encoded value should only allow 3 entries");
+        } catch (IllegalArgumentException e) {
+            assertTrue(e.getMessage().startsWith("Number of values is higher than the maximum value count"));
+        }
+    }
+    
+    @Test
+    public void testNull() {
+        StringEncodedValue prop = new StringEncodedValue("country", 3);
+        prop.init(new EncodedValue.InitializerConfig());
+        
+        IntsRef ref = new IntsRef(1);
+        prop.setString(false, ref, null);
+        assertEquals(0, prop.getValues().size());
+    }
+    
+    @Test
+    public void testEquals() {
+        List<String> values = Arrays.asList("aut", "deu", "che");
+        StringEncodedValue small = new StringEncodedValue("country", 3, values, false);
+        small.init(new EncodedValue.InitializerConfig());
+
+        StringEncodedValue big = new StringEncodedValue("country", 4, values, false);
+        big.init(new EncodedValue.InitializerConfig());
+        
+        assertNotEquals(small, big);
+    }
+    
+    @Test
+    public void testLookup() {
+        StringEncodedValue prop = new StringEncodedValue("country", 3);
+        prop.init(new EncodedValue.InitializerConfig());
+        
+        IntsRef ref = new IntsRef(1);
+        assertEquals(null, prop.getString(false, ref));
+        assertEquals(0, prop.getValues().size());
+
+        prop.setString(false, ref, "aut");
+        assertEquals("aut", prop.getString(false, ref));
+        assertEquals(1, prop.getValues().size());
+        
+        prop.setString(false, ref, "deu");
+        assertEquals("deu", prop.getString(false, ref));
+        assertEquals(2, prop.getValues().size());
+        
+        prop.setString(false, ref, "che");
+        assertEquals("che", prop.getString(false, ref));
+        assertEquals(3, prop.getValues().size());
+        
+        prop.setString(false, ref, "deu");
+        assertEquals("deu", prop.getString(false, ref));
+        assertEquals(3, prop.getValues().size());
+    }
+    
+    @Test
+    public void testStoreTooManyEntries() {
+        StringEncodedValue prop = new StringEncodedValue("country", 3);
+        prop.init(new EncodedValue.InitializerConfig());
+        
+        IntsRef ref = new IntsRef(1);
+        assertEquals(null, prop.getString(false, ref));
+
+        prop.setString(false, ref, "aut");
+        assertEquals("aut", prop.getString(false, ref));
+        
+        prop.setString(false, ref, "deu");
+        assertEquals("deu", prop.getString(false, ref));
+        
+        prop.setString(false, ref, "che");
+        assertEquals("che", prop.getString(false, ref));
+        
+        try {
+            prop.setString(false, ref, "xyz");
+            fail("The encoded value should only allow a limited number of values");
+        } catch (IllegalStateException e) {
+            assertTrue(e.getMessage().startsWith("Maximum number of values reached for"));
+        }
+    }
+}
\ No newline at end of file
diff --git a/reader-osm/src/test/java/com/graphhopper/GraphHopperTest.java b/reader-osm/src/test/java/com/graphhopper/GraphHopperTest.java
index 81689176c..553391486 100644
--- a/reader-osm/src/test/java/com/graphhopper/GraphHopperTest.java
+++ b/reader-osm/src/test/java/com/graphhopper/GraphHopperTest.java
@@ -1623,7 +1623,6 @@ public class GraphHopperTest {
     @ParameterizedTest
     @ValueSource(booleans = {true, false})
     public void testAStarCHBug(boolean turnCosts) {
-        // todo: this test fails when AStar is used for edge-based CH, see #2061
         final String profile = "car";
         final String vehicle = "car";
         final String weighting = "fastest";