Index: trunk/test/data/mapillary.json
===================================================================
--- trunk/test/data/mapillary.json	(revision 17862)
+++ trunk/test/data/mapillary.json	(revision 17862)
@@ -0,0 +1,111 @@
+{
+  "version":8,
+  "name":"Mapillary",
+  "owner":"Mapillary",
+  "id":"mapillary",
+  "sources":{
+      "mapillary-source":{
+        "type":"vector",
+        "tiles":[
+            "https://tiles3.mapillary.com/v0.1/{z}/{x}/{y}.mvt"
+        ],
+        "maxzoom":14
+      },
+      "mapillary-features-source": {
+        "maxzoom": 20,
+        "minzoom": 14,
+        "tiles": [ "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_&layers=points&per_page=1000" ],
+        "type": "vector"
+      },
+      "mapillary-traffic-signs-source": {
+        "maxzoom": 20,
+        "minzoom": 14,
+        "tiles": [ "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_&layers=trafficsigns&per_page=1000" ],
+        "type": "vector"
+      }
+  },
+  "layers":[
+    {
+      "filter": [ "==", "pano", 1 ],
+      "id": "mapillary-panos",
+      "type": "circle",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-images",
+      "minzoom": 17,
+      "paint": {
+        "circle-color": "#05CB63",
+        "circle-opacity": 0.5,
+        "circle-radius": 18
+      }
+    },
+    {
+      "id": "mapillary-dots",
+      "type": "circle",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-images",
+      "interactive": true,
+      "minzoom": 14,
+      "paint": {
+        "circle-color": "#05CB63",
+        "circle-radius": 6
+      }
+    },
+    {
+      "id": "mapillary-lines",
+      "type": "line",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-sequences",
+      "minzoom": 6,
+      "paint": {
+        "line-color": "#05CB63",
+        "line-width": 2
+      }
+    },
+    {
+      "id": "mapillary-overview",
+      "type": "circle",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-sequence-overview",
+      "maxzoom": 6,
+      "paint": {
+        "circle-radius": 4,
+        "circle-opacity": 0.6,
+        "circle-color": "#05CB63"
+      }
+    },
+    {
+      "id": "mapillary-features",
+      "type": "symbol",
+      "source": "mapillary-features-source",
+      "source-layer": "mapillary-map-features",
+      "interactive": true,
+      "minzoom": 14,
+      "layout": {
+        "icon-image": "{value}",
+        "icon-allow-overlap": true,
+        "symbol-avoid-edges": true
+      },
+      "paint": {
+        "text-color": "#fff",
+        "text-halo-color": "#000"
+      }
+    },
+    {
+      "id": "mapillary-traffic-signs",
+      "type": "symbol",
+      "source": "mapillary-traffic-signs-source",
+      "source-layer": "mapillary-map-features",
+      "interactive": true,
+      "minzoom": 14,
+      "layout": {
+        "icon-image": "{value}",
+        "icon-allow-overlap": true,
+        "symbol-avoid-edges": true
+      },
+      "paint": {
+        "text-color": "#fff",
+        "text-halo-color": "#000"
+      }
+    }
+  ]
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java	(revision 17862)
@@ -0,0 +1,122 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import static org.openstreetmap.josm.data.imagery.vectortile.mapbox.LayerTest.getSimpleFeatureLayerBytes;
+import static org.openstreetmap.josm.data.imagery.vectortile.mapbox.LayerTest.getLayer;
+
+import java.text.NumberFormat;
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Feature}
+ */
+class FeatureTest {
+    /**
+     * This can be used to replace bytes 11-14 (inclusive) in {@link LayerTest#simpleFeatureLayerBytes}.
+     */
+    private final byte[] nonPackedTags = new byte[] {0x10, 0x00, 0x10, 0x00};
+
+    @Test
+    void testCreation() {
+        testCreation(getSimpleFeatureLayerBytes());
+    }
+
+    @Test
+    void testCreationUnpacked() {
+        byte[] copyBytes = getSimpleFeatureLayerBytes();
+        System.arraycopy(nonPackedTags, 0, copyBytes, 13, nonPackedTags.length);
+        testCreation(copyBytes);
+    }
+
+    @Test
+    void testCreationTrueToFalse() {
+        byte[] copyBytes = getSimpleFeatureLayerBytes();
+        copyBytes[copyBytes.length - 1] = 0x00; // set value=false
+        Layer layer = assertDoesNotThrow(() -> getLayer(copyBytes));
+        assertSame(Boolean.FALSE, layer.getValue(0));
+    }
+
+    @Test
+    void testNumberGrouping() {
+        // This is the float we are adding
+        // 49 74 24 00 == 1_000_000f
+        // 3f 80 00 00 == 1f
+        byte[] newBytes = new byte[] {0x22, 0x09, 0x15, 0x00, 0x24, 0x74, 0x49};
+        byte[] copyBytes = Arrays.copyOf(getSimpleFeatureLayerBytes(), getSimpleFeatureLayerBytes().length + newBytes.length - 4);
+        // Change last few bytes
+        System.arraycopy(newBytes, 0, copyBytes, 25, newBytes.length);
+        // Update the length of the record
+        copyBytes[1] = (byte) (copyBytes[1] + newBytes.length - 4);
+        final NumberFormat numberFormat = NumberFormat.getNumberInstance();
+        final boolean numberFormatGroupingUsed = numberFormat.isGroupingUsed();
+        // Sanity check
+        Layer layer;
+        try {
+            numberFormat.setGroupingUsed(true);
+            layer = assertDoesNotThrow(() -> getLayer(copyBytes));
+            assertTrue(numberFormat.isGroupingUsed());
+        } finally {
+            numberFormat.setGroupingUsed(numberFormatGroupingUsed);
+        }
+        assertEquals(1, layer.getFeatures().size());
+        assertEquals("t", layer.getName());
+        assertEquals(2, layer.getVersion());
+        assertEquals("a", layer.getKey(0));
+        assertEquals(1_000_000f, ((Number) layer.getValue(0)).floatValue(), 0.00001);
+        
+        // Feature check
+        Feature feature = layer.getFeatures().iterator().next();
+        checkDefaultGeometry(feature);
+        assertEquals("1000000", feature.getTags().get("a"));
+    }
+
+    private void testCreation(byte[] bytes) {
+        Layer layer = assertDoesNotThrow(() -> getLayer(bytes));
+        // Sanity check the layer
+        assertEquals(1, layer.getFeatures().size());
+        assertEquals("t", layer.getName());
+        assertEquals(2, layer.getVersion());
+        assertEquals("a", layer.getKey(0));
+        assertSame(Boolean.TRUE, layer.getValue(0));
+
+        // OK. Get the feature.
+        Feature feature = layer.getFeatures().iterator().next();
+
+        checkDefaultTags(feature);
+
+        // Check id (should be the default of 0)
+        assertEquals(1, feature.getId());
+
+        checkDefaultGeometry(feature);
+    }
+
+    private void checkDefaultTags(Feature feature) {
+        // Check tags
+        assertEquals(1, feature.getTags().size());
+        assertTrue(feature.getTags().containsKey("a"));
+        // We are converting to a tag map (Map<String, String>), so "true"
+        assertEquals("true", feature.getTags().get("a"));
+    }
+
+    private void checkDefaultGeometry(Feature feature) {
+        // Check the geometry
+        assertEquals(GeometryTypes.POINT, feature.getGeometryType());
+        assertEquals(1, feature.getGeometry().size());
+        CommandInteger geometry = feature.getGeometry().get(0);
+        assertEquals(Command.MoveTo, geometry.getType());
+        assertEquals(2, geometry.getOperations().length);
+        assertEquals(25, geometry.getOperations()[0]);
+        assertEquals(17, geometry.getOperations()[1]);
+        assertNotNull(feature.getGeometryObject());
+        assertEquals(feature.getGeometryObject(), feature.getGeometryObject());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java	(revision 17862)
@@ -0,0 +1,169 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Point2D;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Geometry}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class GeometryTest {
+    /**
+     * Create a command integer fairly easily
+     * @param command The command type (see {@link Command})
+     * @param parameters The parameters for the command
+     * @return A command integer
+     */
+    private static CommandInteger createCommandInteger(int command, int... parameters) {
+        CommandInteger commandInteger = new CommandInteger(command);
+        if (parameters != null) {
+            for (int parameter : parameters) {
+                commandInteger.addParameter(parameter);
+            }
+        }
+        return commandInteger;
+    }
+
+    /**
+     * Check the current
+     * @param pathIterator The path to check
+     * @param expected The expected coords
+     */
+    private static void checkCurrentSegmentAndIncrement(PathIterator pathIterator, float... expected) {
+        float[] coords = new float[6];
+        int type = pathIterator.currentSegment(coords);
+        pathIterator.next();
+        for (int i = 0; i < expected.length; i++) {
+            assertEquals(expected[i], coords[i]);
+        }
+        if (Arrays.asList(PathIterator.SEG_MOVETO, PathIterator.SEG_LINETO).contains(type)) {
+            assertEquals(2, expected.length, "You should check both x and y coordinates");
+        } else if (PathIterator.SEG_QUADTO == type) {
+            assertEquals(4, expected.length, "You should check all x and y coordinates");
+        } else if (PathIterator.SEG_CUBICTO == type) {
+            assertEquals(6, expected.length, "You should check all x and y coordinates");
+        } else if (PathIterator.SEG_CLOSE == type) {
+            assertEquals(0, expected.length, "CloseTo has no expected coordinates to check");
+        }
+    }
+
+    @Test
+    void testBadGeometry() {
+        IllegalArgumentException badPointException = assertThrows(IllegalArgumentException.class,
+          () -> new Geometry(GeometryTypes.POINT, Collections.singletonList(createCommandInteger(1))));
+        assertEquals("POINT with 0 arguments is not understood", badPointException.getMessage());
+        IllegalArgumentException badLineException = assertThrows(IllegalArgumentException.class,
+          () -> new Geometry(GeometryTypes.LINESTRING, Collections.singletonList(createCommandInteger(15))));
+        assertEquals("LINESTRING with 0 arguments is not understood", badLineException.getMessage());
+    }
+
+    @Test
+    void testPoint() {
+        CommandInteger moveTo = createCommandInteger(9, 17, 34);
+        Geometry geometry = new Geometry(GeometryTypes.POINT, Collections.singletonList(moveTo));
+        assertEquals(1, geometry.getShapes().size());
+        Ellipse2D shape = (Ellipse2D) geometry.getShapes().iterator().next();
+        assertEquals(17, shape.getCenterX());
+        assertEquals(34, shape.getCenterY());
+    }
+
+    @Test
+    void testLine() {
+        CommandInteger moveTo = createCommandInteger(9, 2, 2);
+        CommandInteger lineTo = createCommandInteger(18, 0, 8, 8, 0);
+        Geometry geometry = new Geometry(GeometryTypes.LINESTRING, Arrays.asList(moveTo, lineTo));
+        assertEquals(1, geometry.getShapes().size());
+        Path2D path = (Path2D) geometry.getShapes().iterator().next();
+        PathIterator pathIterator = path.getPathIterator(null);
+        checkCurrentSegmentAndIncrement(pathIterator, 2, 2);
+        checkCurrentSegmentAndIncrement(pathIterator, 2, 10);
+        checkCurrentSegmentAndIncrement(pathIterator, 10, 10);
+        assertTrue(pathIterator.isDone());
+    }
+
+    @Test
+    void testPolygon() {
+        List<CommandInteger> commands = new ArrayList<>(3);
+        commands.add(createCommandInteger(9, 3, 6));
+        commands.add(createCommandInteger(18, 5, 6, 12, 22));
+        commands.add(createCommandInteger(15));
+
+        Geometry geometry = new Geometry(GeometryTypes.POLYGON, commands);
+        assertEquals(1, geometry.getShapes().size());
+
+        Area area = (Area) geometry.getShapes().iterator().next();
+        PathIterator pathIterator = area.getPathIterator(null);
+        checkCurrentSegmentAndIncrement(pathIterator, 3, 6);
+        // This is somewhat unexpected, and may change based off of JVM implementations
+        // But for whatever reason, Java flips the inner coordinates in this case.
+        checkCurrentSegmentAndIncrement(pathIterator, 20, 34);
+        checkCurrentSegmentAndIncrement(pathIterator, 8, 12);
+        checkCurrentSegmentAndIncrement(pathIterator, 3, 6);
+        checkCurrentSegmentAndIncrement(pathIterator);
+        assertTrue(pathIterator.isDone());
+    }
+
+    @Test
+    void testBadPolygon() {
+        /*
+         * "Linear rings MUST be geometric objects that have no anomalous geometric points,
+         * such as self-intersection or self-tangency. The position of the cursor before
+         * calling the ClosePath command of a linear ring SHALL NOT repeat the same position
+         * as the first point in the linear ring as this would create a zero-length line
+         * segment. A linear ring SHOULD NOT have an area calculated by the surveyor's
+         * formula equal to zero, as this would signify a ring with anomalous geometric points."
+         */
+        List<CommandInteger> commands = new ArrayList<>(3);
+        commands.add(createCommandInteger(9, 0, 0));
+        commands.add(createCommandInteger(18, 0, 0));
+        commands.add(createCommandInteger(15));
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new Geometry(GeometryTypes.POLYGON, commands));
+        assertEquals("POLYGON cannot have zero area", exception.getMessage());
+    }
+
+    @Test
+    void testMultiPolygon() {
+        List<CommandInteger> commands = new ArrayList<>(10);
+        // Polygon 1
+        commands.add(createCommandInteger(9, 0, 0));
+        commands.add(createCommandInteger(26, 10, 0, 0, 10, -10, 0));
+        commands.add(createCommandInteger(15));
+        // Polygon 2 outer
+        commands.add(createCommandInteger(9, 11, 1));
+        commands.add(createCommandInteger(26, 9, 0, 0, 9, -9, 0));
+        commands.add(createCommandInteger(15));
+        // Polygon 2 inner
+        commands.add(createCommandInteger(9, 2, -7));
+        commands.add(createCommandInteger(26, 0, 4, 4, 0, 0, -4));
+        commands.add(createCommandInteger(15));
+
+        Geometry geometry = new Geometry(GeometryTypes.POLYGON, commands);
+        assertEquals(1, geometry.getShapes().size());
+        Area area = (Area) geometry.getShapes().iterator().next();
+        assertFalse(area.isSingular());
+        PathIterator pathIterator = area.getPathIterator(null);
+        assertEquals(PathIterator.WIND_NON_ZERO, pathIterator.getWindingRule());
+        assertTrue(area.contains(new Point2D.Float(5, 5)));
+        assertTrue(area.contains(new Point2D.Float(12, 12)));
+        assertFalse(area.contains(new Point2D.Float(15, 15)));
+        assertFalse(area.contains(new Point2D.Float(10, 11)));
+        assertFalse(area.contains(new Point2D.Float(-1, -1)));
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java	(revision 17862)
@@ -0,0 +1,47 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+import org.openstreetmap.josm.TestUtils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Test class for {@link GeometryTypes}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class GeometryTypesTest {
+    @Test
+    void testNaiveEnumTest() {
+        TestUtils.superficialEnumCodeCoverage(GeometryTypes.class);
+        TestUtils.superficialEnumCodeCoverage(GeometryTypes.Ring.class);
+    }
+
+    @ParameterizedTest
+    @EnumSource(GeometryTypes.class)
+    void testExpectedIds(GeometryTypes type) {
+        // Ensure that users can get the type from the ordinal
+        // See https://github.com/mapbox/vector-tile-spec/blob/master/2.1/vector_tile.proto#L8
+        // for the expected values
+        final int expectedId;
+        if (type == GeometryTypes.UNKNOWN) {
+            expectedId = 0;
+        } else if (type == GeometryTypes.POINT) {
+            expectedId = 1;
+        } else if (type == GeometryTypes.LINESTRING) {
+            expectedId = 2;
+        } else if (type == GeometryTypes.POLYGON) {
+            expectedId = 3;
+        } else {
+            fail("Unknown geometry type, see vector tile spec");
+            expectedId = Integer.MIN_VALUE;
+        }
+        assertEquals(expectedId, type.ordinal());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java	(revision 17862)
@@ -0,0 +1,135 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Layer}
+ */
+public class LayerTest {
+    /**
+     * This looks something like this (if it were json). Note that some keys could be repeated,
+     * and so could be better represented as an array. Specifically, "features", "key", and "value".
+     * "layer": {
+     *     "name": "t",
+     *     "version": 2,
+     *     "features": {
+     *         "type": "POINT",
+     *         "tags": [0, 0],
+     *         "geometry": [9, 50, 34]
+     *     },
+     *     "key": "a",
+     *     "value": true
+     * }
+     *
+     * WARNING: DO NOT MODIFY THIS ARRAY DIRECTLY -- it could contaminate other tests
+     */
+    private static final byte[] simpleFeatureLayerBytes = new byte[] {
+      0x1a, 0x1b, // layer, 27 bytes for the rest
+      0x0a, 0x01, 0x74, // name=t
+      0x78, 0x02, // version=2
+      0x12, 0x0d, // features, 11 bytes
+      0x08, 0x01, // id=1
+      0x18, 0x01, // type=POINT
+      0x12, 0x02, 0x00, 0x00, // tags=[0, 0] (packed). Non-packed would be [0x10, 0x00, 0x10, 0x00]
+      0x22, 0x03, 0x09, 0x32, 0x22, // geometry=[9, 50, 34]
+      0x1a, 0x01, 0x61, // key=a
+      0x22, 0x02, 0x38, 0x01, // value=true (boolean)
+    };
+
+    /**
+     * Gets a copy of {@link #simpleFeatureLayerBytes} so that a test doesn't accidentally change the bytes
+     * @return An array that can be modified.
+     */
+    static byte[] getSimpleFeatureLayerBytes() {
+        return Arrays.copyOf(simpleFeatureLayerBytes, simpleFeatureLayerBytes.length);
+    }
+
+    /**
+     * Create a layer from bytes
+     * @param bytes The bytes that make up the layer
+     * @return The generated layer
+     * @throws IOException If something happened (should never trigger)
+     */
+    static Layer getLayer(byte[] bytes) throws IOException {
+        List<ProtobufRecord> records = (List<ProtobufRecord>) new ProtobufParser(bytes).allRecords();
+        assertEquals(1, records.size());
+        return new Layer(new ProtobufParser(records.get(0).getBytes()).allRecords());
+    }
+
+    @Test
+    void testLayerCreation() throws IOException {
+        List<ProtobufRecord> layers = (List<ProtobufRecord>) new ProtobufParser(new FileInputStream(TestUtils.getTestDataRoot()
+          + "pbf/mapillary/14/3249/6258.mvt")).allRecords();
+        Layer sequenceLayer = new Layer(layers.get(0).getBytes());
+        assertEquals("mapillary-sequences", sequenceLayer.getName());
+        assertEquals(1, sequenceLayer.getFeatures().size());
+        assertEquals(1, sequenceLayer.getGeometry().size());
+        assertEquals(4096, sequenceLayer.getExtent());
+        assertEquals(1, sequenceLayer.getVersion());
+
+        Layer imageLayer = new Layer(layers.get(1).getBytes());
+        assertEquals("mapillary-images", imageLayer.getName());
+        assertEquals(116, imageLayer.getFeatures().size());
+        assertEquals(116, imageLayer.getGeometry().size());
+        assertEquals(4096, imageLayer.getExtent());
+        assertEquals(1, imageLayer.getVersion());
+    }
+
+    @Test
+    void testLayerEqualsHashCode() throws IOException {
+        List<ProtobufRecord> layers = (List<ProtobufRecord>) new ProtobufParser(new FileInputStream(TestUtils.getTestDataRoot()
+          + "pbf/mapillary/14/3249/6258.mvt")).allRecords();
+        EqualsVerifier.forClass(Layer.class).withPrefabValues(byte[].class, layers.get(0).getBytes(), layers.get(1).getBytes())
+          .verify();
+    }
+
+    @Test
+    void testVersionsNumbers() {
+        byte[] copyByte = getSimpleFeatureLayerBytes();
+        assertEquals(2, assertDoesNotThrow(() -> getLayer(copyByte)).getVersion());
+        copyByte[6] = 1;
+        assertEquals(1, assertDoesNotThrow(() -> getLayer(copyByte)).getVersion());
+        copyByte[6] = 0;
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("We do not understand version 0 of the vector tile specification", exception.getMessage());
+        copyByte[6] = 3;
+        exception = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("We do not understand version 3 of the vector tile specification", exception.getMessage());
+        // Remove version number (AKA change it to some unknown field). Default is version=1.
+        copyByte[5] = 0x18;
+        assertEquals(1, assertDoesNotThrow(() -> getLayer(copyByte)).getVersion());
+    }
+
+    @Test
+    void testLayerName() throws IOException {
+        byte[] copyByte = getSimpleFeatureLayerBytes();
+        Layer layer = getLayer(copyByte);
+        assertEquals("t", layer.getName());
+        copyByte[2] = 0x1a; // name=t -> ?
+        Exception noNameException = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("Vector tile layers must have a layer name", noNameException.getMessage());
+    }
+
+    @Test
+    void testUnknownField() {
+        byte[] copyByte = getSimpleFeatureLayerBytes();
+        copyByte[27] = 0x78;
+        Exception unknownField = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("Unknown field in vector tile layer value (15)", unknownField.getMessage());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java	(revision 17862)
@@ -0,0 +1,80 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.awt.image.BufferedImage;
+import java.util.Collections;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.cache.JCSCacheManager;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test class for {@link MVTTile}
+ */
+public class MVTTileTest {
+    private MapboxVectorTileSource tileSource;
+    private MapboxVectorCachedTileLoader loader;
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules();
+    @BeforeEach
+    void setup() {
+        tileSource = new MapboxVectorTileSource(new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot()
+          + "pbf/mapillary/{z}/{x}/{y}.mvt"));
+        loader = new MapboxVectorCachedTileLoader(null,
+          JCSCacheManager.getCache("testMapillaryCache"), new TileJobOptions(1, 1, Collections
+          .emptyMap(), 3600));
+    }
+
+    /**
+     * Provide arguments for {@link #testMVTTile(BufferedImage, Boolean)}
+     * @return The arguments to use
+     */
+    private static Stream<Arguments> testMVTTile() {
+        return Stream.of(
+          Arguments.of(null, Boolean.TRUE),
+          Arguments.of(Tile.LOADING_IMAGE, Boolean.TRUE),
+          Arguments.of(Tile.ERROR_IMAGE, Boolean.TRUE),
+          Arguments.of(new BufferedImage(1, 1, BufferedImage.TYPE_BYTE_GRAY), Boolean.FALSE)
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("testMVTTile")
+    void testMVTTile(BufferedImage image, Boolean isLoaded) {
+        MVTTile tile = new MVTTile(tileSource, 3249, 6258, 14);
+        tile.setImage(image);
+        assertEquals(image, tile.getImage());
+
+        TileJob job = loader.createTileLoaderJob(tile);
+        job.submit();
+        Awaitility.await().atMost(Durations.ONE_SECOND).until(tile::isLoaded);
+        if (isLoaded) {
+            Awaitility.await().atMost(Durations.ONE_SECOND).until(() -> tile.getLayers() != null && tile.getLayers().size() > 1);
+            assertEquals(2, tile.getLayers().size());
+            assertEquals(4096, tile.getExtent());
+            // Ensure that we have the clear image set, such that the tile doesn't add to the dataset again
+            // and we don't have a loading image
+            assertEquals(MVTTile.CLEAR_LOADED, tile.getImage());
+        } else {
+            assertNull(tile.getLayers());
+            assertEquals(image, tile.getImage());
+        }
+    }
+
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java	(revision 17862)
@@ -0,0 +1,77 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapboxVectorStyle;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.Source;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.gui.widgets.JosmComboBox;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.mockers.ExtendedDialogMocker;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test class for {@link MapboxVectorTileSource}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class MapboxVectorTileSourceTest {
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules();
+    private static class SelectLayerDialogMocker extends ExtendedDialogMocker {
+        int index;
+        @Override
+        protected void act(final ExtendedDialog instance) {
+            ((JosmComboBox<?>) this.getContent(instance)).setSelectedIndex(index);
+        }
+
+        @Override
+        protected String getString(final ExtendedDialog instance) {
+            return String.join(";", ((Source) ((JosmComboBox<?>) this.getContent(instance)).getSelectedItem()).getUrls());
+        }
+    }
+
+    @Test
+    void testNoStyle() {
+        MapboxVectorTileSource tileSource = new MapboxVectorTileSource(
+          new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot() + "pbf/mapillary/{z}/{x}/{y}.mvt"));
+        assertNull(tileSource.getStyleSource());
+    }
+
+    private static Stream<Arguments> testMapillaryStyle() {
+        return Stream.of(Arguments.of(0, "Test Mapillary: mapillary-source", "https://tiles3.mapillary.com/v0.1/{z}/{x}/{y}.mvt"),
+          Arguments.of(1, "Test Mapillary: mapillary-features-source",
+            "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_"
+              + "&layers=points&per_page=1000"),
+          Arguments.of(2, "Test Mapillary: mapillary-traffic-signs-source",
+            "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_"
+              + "&layers=trafficsigns&per_page=1000"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("testMapillaryStyle")
+    void testMapillaryStyle(Integer index, String expected, String dialogMockerText) {
+        TestUtils.assumeWorkingJMockit();
+        SelectLayerDialogMocker extendedDialogMocker = new SelectLayerDialogMocker();
+        extendedDialogMocker.index = index;
+        extendedDialogMocker.getMockResultMap().put(dialogMockerText, "Add layers");
+        MapboxVectorTileSource tileSource = new MapboxVectorTileSource(
+          new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot() + "mapillary.json"));
+        MapboxVectorStyle styleSource = tileSource.getStyleSource();
+        assertNotNull(styleSource);
+        assertEquals(expected, tileSource.toString());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java	(revision 17862)
@@ -0,0 +1,53 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+import javax.json.Json;
+import javax.json.JsonValue;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Expression}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ExpressionTest {
+    @Test
+    void testInvalidJson() {
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.NULL));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.FALSE));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.TRUE));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.EMPTY_JSON_OBJECT));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.EMPTY_JSON_ARRAY));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createObjectBuilder().add("bad", "value").build()));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createValue(1)));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createValue(1.0)));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createValue("bad string")));
+    }
+
+    @Test
+    void testBasicExpressions() {
+        // "filter": [ "==|>=|<=|<|>", "key", "value" ]
+        assertEquals("[key=value]", new Expression(Json.createArrayBuilder().add("==").add("key").add("value").build()).toString());
+        assertEquals("[key>=true]", new Expression(Json.createArrayBuilder().add(">=").add("key").add(true).build()).toString());
+        assertEquals("[key<=false]", new Expression(Json.createArrayBuilder().add("<=").add("key").add(false).build()).toString());
+        assertEquals("[key<1]", new Expression(Json.createArrayBuilder().add("<").add("key").add(1).build()).toString());
+        assertEquals("[key>2.5]", new Expression(Json.createArrayBuilder().add(">").add("key").add(2.5).build()).toString());
+        // Test bad expression
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createArrayBuilder().add(">>").add("key").add("value").build()));
+
+        // Test expressions with a subarray and object. This is expected to fail when properly supported, so it should be fixed.
+        assertEquals("[key=[{bad:value}]]", new Expression(Json.createArrayBuilder().add("==").add("key").add(
+          Json.createArrayBuilder().add(Json.createObjectBuilder().add("bad", "value"))).build()).toString());
+        assertEquals("[key=]", new Expression(Json.createArrayBuilder().add("==").add("key").add(JsonValue.NULL).build()).toString());
+    }
+
+    @Test
+    void testEquals() {
+        EqualsVerifier.forClass(Expression.class).verify();
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java	(revision 17862)
@@ -0,0 +1,601 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.text.MessageFormat;
+import java.util.Locale;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonValue;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Layers}.
+ * @implNote Tests will fail when support is added for new styling information.
+ * All current (2021-03-31) properties are checked for in some form or another.
+ * @author Taylor Smock
+ * @since xxx
+ */
+class LayersTest {
+    @Test
+    void testBackground() {
+        // Test an empty background layer
+        Layers emptyBackgroundLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.BACKGROUND.name())
+          .add("id", "Empty Background").build());
+        assertEquals("Empty Background", emptyBackgroundLayer.getId());
+        assertEquals(Layers.Type.BACKGROUND, emptyBackgroundLayer.getType());
+        assertNull(emptyBackgroundLayer.getSource());
+        assertSame(Expression.EMPTY_EXPRESSION, emptyBackgroundLayer.getFilter());
+        assertEquals("", emptyBackgroundLayer.toString());
+
+        // Test a background layer with some styling information
+        JsonObject allProperties = Json.createObjectBuilder()
+          .add("background-color", "#fff000") // fill-color:#fff000;
+          .add("background-opacity", 0.5) // No good mapping for JOSM yet
+          .add("background-pattern", "null") // This should be an image, not implemented
+          .build();
+        Layers backgroundLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Background layer")
+          .add("type", Layers.Type.BACKGROUND.name().toLowerCase(Locale.ROOT))
+          .add("paint", allProperties)
+        .build());
+        assertEquals("canvas{fill-color:#fff000;}", backgroundLayer.toString());
+
+        // Test a background layer with some styling information, but invisible
+        Layers invisibleBackgroundLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Background layer")
+          .add("type", Layers.Type.BACKGROUND.name().toLowerCase(Locale.ROOT))
+          .add("layout", Json.createObjectBuilder().add("visibility", "none").build())
+          .add("paint", allProperties).build());
+        assertEquals("", invisibleBackgroundLayer.toString());
+    }
+
+    @Test
+    void testFill() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.name())
+          .add("id", "Empty Fill").build()));
+
+        // Test an empty fill layer
+        Layers emptyFillLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.name())
+          .add("id", "Empty Fill")
+          .add("source", "Random source").build());
+        assertEquals("Empty Fill", emptyFillLayer.getId());
+        assertEquals("Random source", emptyFillLayer.getSource());
+        assertEquals("", emptyFillLayer.toString());
+
+        // Test a fully implemented fill layer
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("fill-sort-key", 5)
+          .add("visibility", "visible")
+          .build();
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("fill-antialias", false)
+          .add("fill-color", "#fff000") // fill-color:#fff000
+          .add("fill-opacity", 0.5) // fill-opacity:0.5
+          .add("fill-outline-color", "#ffff00") // fill-color:#ffff00 (defaults to fill-color)
+          .add("fill-pattern", JsonValue.NULL) // disables fill-outline-color and fill-color
+          .add("fill-translate", Json.createArrayBuilder().add(5).add(5))
+          .add("fill-translate-anchor", "viewport") // requires fill-translate
+          .build();
+
+        Layers fullFillLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.toString())
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullFillLayer.getId());
+        assertEquals(Layers.Type.FILL, fullFillLayer.getType());
+        assertEquals("area::random-layer-id{fill-color:#fff000;fill-opacity:0.5;color:#ffff00;}", fullFillLayer.toString());
+
+        // Test a fully implemented fill layer (invisible)
+        Layers fullFillInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.toString())
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties)
+            .add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullFillInvisibleLayer.getId());
+        assertEquals(Layers.Type.FILL, fullFillInvisibleLayer.getType());
+        assertEquals("", fullFillInvisibleLayer.toString());
+    }
+
+    @Test
+    void testLine() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.LINE.name())
+          .add("id", "Empty Line").build()));
+
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("line-cap", "round") // linecap:round;
+          .add("line-join", "bevel")
+          .add("line-miter-limit", 65)
+          .add("line-round-limit", 1.5)
+          .add("line-sort-key", 3)
+          .add("visibility", "visible")
+          .build();
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("line-blur", 5)
+          .add("line-color", "#fff000") // color:#fff000;
+          .add("line-dasharray", Json.createArrayBuilder().add(1).add(5).add(1)) // dashes:1,5,1;
+          .add("line-gap-width", 6)
+          .add("line-gradient", "#ffff00") // disabled by line-dasharray/line-pattern, source must be "geojson"
+          .add("line-offset", 12)
+          .add("line-opacity", 0.5) // opacity:0.5;
+          .add("line-pattern", JsonValue.NULL)
+          .add("line-translate", Json.createArrayBuilder().add(-1).add(-2))
+          .add("line-translate-anchor", "viewport")
+          .add("line-width", 22) // width:22;
+          .build();
+
+        // Test fully defined line
+        Layers fullLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.LINE.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineLayer.getId());
+        assertEquals(Layers.Type.LINE, fullLineLayer.getType());
+        assertEquals("way::random-layer-id{color:#fff000;opacity:0.5;linecap:round;dashes:1,5,1;width:22;}", fullLineLayer.toString());
+
+        // Test invisible line
+        Layers fullLineInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.LINE.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties)
+            .add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineInvisibleLayer.getId());
+        assertEquals(Layers.Type.LINE, fullLineInvisibleLayer.getType());
+        assertEquals("", fullLineInvisibleLayer.toString());
+    }
+
+    @Test
+    void testSymbol() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name())
+          .add("id", "Empty Symbol").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("icon-color", "#fff000") // also requires sdf icons
+          .add("icon-halo-blur", 5)
+          .add("icon-halo-color", "#ffff00")
+          .add("icon-halo-width", 6)
+          .add("icon-opacity", 0.5) // icon-opacity:0.5;
+          .add("icon-translate", Json.createArrayBuilder().add(11).add(12))
+          .add("icon-translate-anchor", "viewport") // also requires icon-translate
+          .add("text-color", "#fffff0") // text-color:#fffff0;
+          .add("text-halo-blur", 15)
+          .add("text-halo-color", "#ffffff") // text-halo-color:#ffffff;
+          .add("text-halo-width", 16) // text-halo-radius:16;
+          .add("text-opacity", 0.6) // text-opacity:0.6;
+          .add("text-translate", Json.createArrayBuilder().add(26).add(27))
+          .add("text-translate-anchor", "viewport")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("icon-allow-overlap", true)
+          .add("icon-anchor", "left")
+          .add("icon-ignore-placement", true)
+          .add("icon-image", "random-image") // icon-image:concat(\"random-image\");
+          .add("icon-keep-upright", true) // also requires icon-rotation-alignment=map and symbol-placement=line|line-center
+          .add("icon-offset", Json.createArrayBuilder().add(2).add(3)) // icon-offset-x:2.0;icon-offset-y:3.0;
+          .add("icon-optional", true) // also requires text-field
+          .add("icon-padding", 4)
+          .add("icon-pitch-alignment", "viewport")
+          .add("icon-rotate", 30) // icon-rotation:30.0;
+          .add("icon-rotation-alignment", "map")
+          .add("icon-size", 2)
+          .add("icon-text-fit", "width") // also requires text-field
+          .add("icon-text-fit-padding", Json.createArrayBuilder().add(7).add(8).add(9).add(10))
+          .add("symbol-avoid-edges", true)
+          .add("symbol-placement", "line")
+          .add("symbol-sort-key", 13)
+          .add("symbol-spacing", 14) // requires symbol-placement=line
+          .add("symbol-z-order", "source")
+          .add("text-allow-overlap", true) // requires text-field
+          .add("text-anchor", "left") // requires text-field, disabled by text-variable-anchor
+          .add("text-field", "something") // text:something;
+          .add("text-font", Json.createArrayBuilder().add("SansSerif")) // DroidSans isn't always available in an IDE
+          .add("text-ignore-placement", true)
+          .add("text-justify", "left")
+          .add("text-keep-upright", false)
+          .add("text-letter-spacing", 17)
+          .add("text-line-height", 1.3)
+          .add("text-max-angle", 18)
+          .add("text-max-width", 19)
+          .add("text-offset", Json.createArrayBuilder().add(20).add(21))
+          .add("text-optional", true)
+          .add("text-padding", 22)
+          .add("text-pitch-alignment", "viewport")
+          .add("text-radial-offset", 23)
+          .add("text-rotate", 24)
+          .add("text-rotation-alignment", "viewport")
+          .add("text-size", 25) // font-size:25;
+          .add("text-transform", "uppercase")
+          .add("text-variable-anchor", "left")
+          .add("text-writing-mode", "vertical")
+          .add("visibility", "visible").build();
+
+        // Test fully defined symbol
+        Layers fullLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineLayer.getId());
+        assertEquals(Layers.Type.SYMBOL, fullLineLayer.getType());
+        assertEquals("node::random-layer-id{icon-image:concat(\"random-image\");icon-offset-x:2.0;icon-offset-y:3.0;"
+          + "icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";font-weight:normal;"
+          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:8;text-opacity:0.6;font-size:25;}", fullLineLayer.toString());
+
+        // Test an invisible symbol
+        Layers fullLineInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineInvisibleLayer.getId());
+        assertEquals(Layers.Type.SYMBOL, fullLineInvisibleLayer.getType());
+        assertEquals("", fullLineInvisibleLayer.toString());
+
+        // Test with placeholders in icon-image
+        Layers fullOneIconImagePlaceholderLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("icon-image", "{value}"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("node::random-layer-id{icon-image:concat(tag(\"value\"));icon-offset-x:2.0;icon-offset-y:3.0;"
+          + "icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";font-weight:normal;"
+          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:8;text-opacity:0.6;font-size:25;}",
+          fullOneIconImagePlaceholderLineLayer.toString());
+
+        // Test with placeholders in icon-image
+        Layers fullOneIconImagePlaceholderExtraLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("icon-image", "something/{value}/random"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("node::random-layer-id{icon-image:concat(\"something/\",tag(\"value\"),\"/random\");icon-offset-x:2.0;"
+          + "icon-offset-y:3.0;icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";"
+          + "font-weight:normal;font-style:normal;text-halo-color:#ffffff;text-halo-radius:8;text-opacity:0.6;font-size:25;}",
+          fullOneIconImagePlaceholderExtraLineLayer.toString());
+
+        // Test with placeholders in icon-image
+        Layers fullTwoIconImagePlaceholderExtraLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("icon-image", "something/{value}/random/{value2}"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("node::random-layer-id{icon-image:concat(\"something/\",tag(\"value\"),\"/random/\",tag(\"value2\"));"
+          + "icon-offset-x:2.0;icon-offset-y:3.0;icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;"
+          + "font-family:\"SansSerif\";font-weight:normal;font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;"
+          + "text-opacity:0.6;font-size:25;}", fullTwoIconImagePlaceholderExtraLineLayer.toString());
+    }
+
+    @Test
+    void testRaster() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.RASTER.name())
+          .add("id", "Empty Raster").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("raster-brightness-max", 0.5)
+          .add("raster-brightness-min", 0.6)
+          .add("raster-contrast", 0.7)
+          .add("raster-fade-duration", 1)
+          .add("raster-hue-rotate", 2)
+          .add("raster-opacity", 0.7)
+          .add("raster-resampling", "nearest")
+          .add("raster-saturation", 0.8)
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        // Test fully defined raster
+        Layers fullRaster = new Layers(Json.createObjectBuilder()
+          .add("id", "test-raster")
+          .add("type", Layers.Type.RASTER.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals(Layers.Type.RASTER, fullRaster.getType());
+        assertEquals("test-raster", fullRaster.getId());
+        assertEquals("Random source", fullRaster.getSource());
+        assertEquals("", fullRaster.toString());
+
+        // Test fully defined invisible raster
+        Layers fullInvisibleRaster = new Layers(Json.createObjectBuilder()
+          .add("id", "test-raster")
+          .add("type", Layers.Type.RASTER.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("", fullInvisibleRaster.toString());
+    }
+
+    @Test
+    void testCircle() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.CIRCLE.name())
+          .add("id", "Empty Circle").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("circle-blur", 1)
+          .add("circle-color", "#fff000") // symbol-fill-color:#fff000;
+          .add("circle-opacity", 0.5) // symbol-fill-opacity:0.5;
+          .add("circle-pitch-alignment", "map")
+          .add("circle-pitch-scale", "viewport")
+          .add("circle-radius", 2) // symbol-size:4.0; (we use width)
+          .add("circle-stroke-color", "#ffff00") // symbol-stroke-color:#ffff00;
+          .add("circle-stroke-opacity", 0.6) // symbol-stroke-opacity:0.6;
+          .add("circle-stroke-width", 5) // symbol-stroke-width:5.0;
+          .add("circle-translate", Json.createArrayBuilder().add(3).add(4))
+          .add("circle-translate-anchor", "viewport")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("circle-sort-key", 3)
+          .add("visibility", "visible")
+          .build();
+
+        Layers fullCircleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full circle layer")
+          .add("type", Layers.Type.CIRCLE.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals(Layers.Type.CIRCLE, fullCircleLayer.getType());
+        assertEquals("Full circle layer", fullCircleLayer.getId());
+        assertEquals("Random source", fullCircleLayer.getSource());
+        assertEquals("node::Full circle layer{symbol-shape:circle;symbol-fill-color:#fff000;symbol-fill-opacity:0.5;"
+          + "symbol-size:4.0;symbol-stroke-color:#ffff00;symbol-stroke-opacity:0.6;symbol-stroke-width:5;}", fullCircleLayer.toString());
+
+        Layers fullCircleInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full circle layer")
+          .add("type", Layers.Type.CIRCLE.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals(Layers.Type.CIRCLE, fullCircleInvisibleLayer.getType());
+        assertEquals("Full circle layer", fullCircleInvisibleLayer.getId());
+        assertEquals("Random source", fullCircleInvisibleLayer.getSource());
+        assertEquals("", fullCircleInvisibleLayer.toString());
+    }
+
+    @Test
+    void testFillExtrusion() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL_EXTRUSION.name())
+          .add("id", "Empty Fill Extrusion").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("fill-extrusion-base", 1)
+          .add("fill-extrusion-color", "#fff000")
+          .add("fill-extrusion-height", 2)
+          .add("fill-extrusion-opacity", 0.5)
+          .add("fill-extrusion-pattern", "something-random")
+          .add("fill-extrusion-translate", Json.createArrayBuilder().add(3).add(4))
+          .add("fill-extrusion-translate-anchor", "viewport")
+          .add("fill-extrusion-vertical-gradient", false)
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        Layers fullFillLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Fill Extrusion")
+          .add("type", Layers.Type.FILL_EXTRUSION.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("", fullFillLayer.toString());
+        assertEquals(Layers.Type.FILL_EXTRUSION, fullFillLayer.getType());
+        Layers fullFillInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Fill Extrusion")
+          .add("type", Layers.Type.FILL_EXTRUSION.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("", fullFillInvisibleLayer.toString());
+        assertEquals(Layers.Type.FILL_EXTRUSION, fullFillInvisibleLayer.getType());
+    }
+
+    @Test
+    void testHeatmap() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.HEATMAP.name())
+          .add("id", "Empty Heatmap").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("heatmap-color", "#fff000") // This will probably be a gradient of some type
+          .add("heatmap-intensity", 0.5)
+          .add("heatmap-opacity", 0.6)
+          .add("heatmap-radius", 1) // This is in pixels
+          .add("heatmap-weight", 0.7)
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        Layers fullHeatmapLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full heatmap")
+          .add("type", Layers.Type.HEATMAP.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", allLayoutProperties)
+          .build());
+        assertEquals(Layers.Type.HEATMAP, fullHeatmapLayer.getType());
+        assertEquals("", fullHeatmapLayer.toString());
+
+        Layers fullHeatmapInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full heatmap")
+          .add("type", Layers.Type.HEATMAP.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .build());
+        assertEquals(Layers.Type.HEATMAP, fullHeatmapInvisibleLayer.getType());
+        assertEquals("", fullHeatmapInvisibleLayer.toString());
+    }
+
+    @Test
+    void testHillshade() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.HILLSHADE.name())
+          .add("id", "Empty Hillshade").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("hillshade-accent-color", "#fff000")
+          .add("hillshade-exaggeration", 0.6)
+          .add("hillshade-highlight-color", "#ffff00")
+          .add("hillshade-illumination-anchor", "map")
+          .add("hillshade-illumination-direction", 90)
+          .add("hillshade-shadow-color", "#fffff0")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("visibility", "visible")
+          .build();
+
+        Layers fullHillshadeLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Hillshade")
+          .add("type", Layers.Type.HILLSHADE.toString().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", allLayoutProperties)
+          .build());
+        assertEquals(Layers.Type.HILLSHADE, fullHillshadeLayer.getType());
+        assertEquals("", fullHillshadeLayer.toString());
+
+        Layers fullHillshadeInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Hillshade")
+          .add("type", Layers.Type.HILLSHADE.toString().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .build());
+        assertEquals(Layers.Type.HILLSHADE, fullHillshadeInvisibleLayer.getType());
+        assertEquals("", fullHillshadeInvisibleLayer.toString());
+    }
+
+    @Test
+    void testSky() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SKY.name())
+          .add("id", "Empty Sky").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("sky-atmosphere-color", "red")
+          .add("sky-atmosphere-halo-color", "yellow")
+          // 360180 is apparently included in this? Or it might be a formatting issue in the docs.
+          .add("sky-atmosphere-sun", Json.createArrayBuilder().add(0, 360180))
+          .add("sky-atmosphere-sun-intensity", 99)
+          .add("sky-gradient", "#fff000")
+          .add("sky-gradient-center", Json.createArrayBuilder().add(0).add(360180)) // see note on 360180 above
+          .add("sky-gradient-radius", 1)
+          .add("sky-opacity", 0.5)
+          .add("sky-type", "gradient")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        Layers fullSkyLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Sky")
+          .add("type", Layers.Type.SKY.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", allLayoutProperties)
+          .build());
+        assertEquals(Layers.Type.SKY, fullSkyLayer.getType());
+        assertEquals("", fullSkyLayer.toString());
+
+        Layers fullSkyInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Sky")
+          .add("type", Layers.Type.SKY.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .build());
+        assertEquals(Layers.Type.SKY, fullSkyInvisibleLayer.getType());
+        assertEquals("", fullSkyInvisibleLayer.toString());
+    }
+
+    @Test
+    void testZoomLevels() {
+        JsonObject baseInformation = Json.createObjectBuilder()
+          .add("id", "dots")
+          .add("type", "CiRcLe")
+          .add("source", "osm-source")
+          .add("source-layer", "osm-images")
+          .add("paint", Json.createObjectBuilder()
+            .add("circle-color", "#fff000")
+            .add("circle-radius", 6)
+          ).build();
+        Layers noZoomLayer = new Layers(baseInformation);
+        String baseString = "node{0}::dots'{symbol-shape:circle;symbol-fill-color:#fff000;symbol-fill-opacity:1;"
+          + "symbol-size:12.0;symbol-stroke-color:#000000;symbol-stroke-opacity:1;symbol-stroke-width:0;}'";
+        assertEquals("osm-images", noZoomLayer.getSourceLayer());
+        assertEquals(MessageFormat.format(baseString, ""), noZoomLayer.toString());
+
+        Layers minZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("minzoom", 0)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z0-"), minZoomLayer.toString());
+
+        Layers maxZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("maxzoom", 24)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z-24"), maxZoomLayer.toString());
+
+        Layers minMaxZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("minzoom", 1)
+          .add("maxzoom", 2)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z1-2"), minMaxZoomLayer.toString());
+
+        Layers sameMinMaxZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("minzoom", 2)
+          .add("maxzoom", 2)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z2"), sameMinMaxZoomLayer.toString());
+    }
+
+    @Test
+    void testEquals() {
+        EqualsVerifier.forClass(Layers.class).usingGetClass().verify();
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyleTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyleTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyleTest.java	(revision 17862)
@@ -0,0 +1,300 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+
+import javax.imageio.ImageIO;
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonReader;
+import javax.json.JsonStructure;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.gui.mappaint.Keyword;
+import org.openstreetmap.josm.gui.mappaint.StyleSource;
+import org.openstreetmap.josm.gui.mappaint.mapcss.Instruction;
+import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSRule;
+import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.tools.ColorHelper;
+import org.openstreetmap.josm.tools.ImageProvider;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Test class for {@link MapboxVectorStyle}
+ * @author Taylor Smock
+ */
+public class MapboxVectorStyleTest {
+    /** Used to store sprite files (specifically, sprite{,@2x}.{png,json}) */
+    @TempDir
+    File spritesDirectory;
+
+    // Needed for osm primitives (we really just need to initialize the config)
+    // OSM primitives are called when we load style sources
+    @RegisterExtension
+    JOSMTestRules rules = new JOSMTestRules();
+
+    /** The base information */
+    private static final String BASE_STYLE = "'{'\"version\":8,\"name\":\"test style\",\"owner\":\"josm test\",\"id\":\"{0}\",{1}'}'";
+    /** Source 1 */
+    private static final String SOURCE1 = "\"source1\":{\"type\":\"vector\",\"tiles\":[\"https://example.org/{z}/{x}/{y}.mvt\"]}";
+    /** Layer 1 */
+    private static final String LAYER1 = "{\"id\":\"layer1\",\"type\":\"circle\",\"source\":\"source1\",\"source-layer\":\"nodes\"}";
+    /** Source 2 */
+    private static final String SOURCE2 = "\"source2\":{\"type\":\"vector\",\"tiles\":[\"https://example.org/{z}2/{x}/{y}.mvt\"]}";
+    /** Layer 2 */
+    private static final String LAYER2 = "{\"id\":\"layer2\",\"type\":\"circle\",\"source\":\"source2\",\"source-layer\":\"nodes\"}";
+
+    /**
+     * Check that the version matches the supported style version(s). Currently, only version 8 exists and is (partially)
+     * supported.
+     */
+    @Test
+    void testVersionChecks() {
+        assertThrows(NullPointerException.class, () -> new MapboxVectorStyle(JsonValue.EMPTY_JSON_OBJECT));
+        IllegalArgumentException badVersion = assertThrows(IllegalArgumentException.class,
+          () -> new MapboxVectorStyle(Json.createObjectBuilder().add("version", 7).build()));
+        assertEquals("Vector Tile Style Version not understood: version 7 (json: {\"version\":7})", badVersion.getMessage());
+        badVersion = assertThrows(IllegalArgumentException.class,
+          () -> new MapboxVectorStyle(Json.createObjectBuilder().add("version", 9).build()));
+        assertEquals("Vector Tile Style Version not understood: version 9 (json: {\"version\":9})", badVersion.getMessage());
+        assertDoesNotThrow(() -> new MapboxVectorStyle(Json.createObjectBuilder().add("version", 8).build()));
+    }
+
+    @Test
+    void testSources() {
+        // Check with an invalid sources list
+        assertTrue(new MapboxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
+        Map<Source, ElemStyles> sources = new MapboxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
+          MessageFormat.format("\"sources\":'{'{0},{1},\"source3\":[\"bad source\"]'}',\"layers\":[{2},{3},{4}]",
+            SOURCE1, SOURCE2, LAYER1, LAYER2, LAYER2.replace('2', '3'))))).getSources();
+        assertEquals(3, sources.size());
+        assertTrue(sources.containsKey(null)); // This is due to there being no source3 layer
+        sources.remove(null); // Avoid null checks later
+        assertTrue(sources.keySet().stream().map(Source::getName).anyMatch("source1"::equals));
+        assertTrue(sources.keySet().stream().map(Source::getName).anyMatch("source2"::equals));
+        assertTrue(sources.keySet().stream().map(Source::getName).noneMatch("source3"::equals));
+    }
+
+    @Test
+    void testSavedFiles() {
+        assertTrue(new MapboxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
+        Map<Source, ElemStyles> sources = new MapboxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
+          MessageFormat.format("\"sources\":'{'{0},{1}'}',\"layers\":[{2},{3}]", SOURCE1, SOURCE2, LAYER1, LAYER2)))).getSources();
+        assertEquals(2, sources.size());
+        // For various reasons, the map _must_ be reliably ordered in the order of encounter
+        Source source1 = sources.keySet().iterator().next();
+        Source source2 = sources.keySet().stream().skip(1).findFirst().orElseGet(() -> fail("No second source"));
+        assertEquals("source1", source1.getName());
+        assertEquals("source2", source2.getName());
+
+        // Check that the files have been saved. Ideally, we would check that they haven't been
+        // saved earlier, since this is in a different thread. Unfortunately, that is a _race condition_.
+        MapCSSStyleSource styleSource1 = (MapCSSStyleSource) sources.get(source1).getStyleSources().get(0);
+        MapCSSStyleSource styleSource2 = (MapCSSStyleSource) sources.get(source2).getStyleSources().get(0);
+
+        AtomicBoolean saveFinished = new AtomicBoolean();
+        MainApplication.worker.execute(() -> saveFinished.set(true));
+        Awaitility.await().atMost(Durations.ONE_SECOND).until(saveFinished::get);
+
+        assertTrue(styleSource1.url.endsWith("source1.mapcss"));
+        assertTrue(styleSource2.url.endsWith("source2.mapcss"));
+
+        MapCSSStyleSource mapCSSStyleSource1 = new MapCSSStyleSource(styleSource1.url, styleSource1.name, styleSource1.title);
+        MapCSSStyleSource mapCSSStyleSource2 = new MapCSSStyleSource(styleSource2.url, styleSource2.name, styleSource2.title);
+
+        assertEquals(styleSource1, mapCSSStyleSource1);
+        assertEquals(styleSource2, mapCSSStyleSource2);
+    }
+
+    @Test
+    void testSprites() throws IOException {
+        generateSprites(false);
+        // Ensure that we fall back to 1x sprites
+        assertTrue(new File(this.spritesDirectory, "sprite.png").exists());
+        assertFalse(new File(this.spritesDirectory, "sprite@2x.png").exists());
+        assertTrue(new File(this.spritesDirectory, "sprite.json").exists());
+        assertFalse(new File(this.spritesDirectory, "sprite@2x.json").exists());
+
+        checkImages(false);
+
+        generateSprites(true);
+        checkImages(true);
+    }
+
+    private void checkImages(boolean hiDpi) {
+        // Ensure that we don't have images saved in the ImageProvider cache
+        ImageProvider.clearCache();
+        int hiDpiScalar = hiDpi ? 2 : 1;
+        String spritePath = new File(this.spritesDirectory, "sprite").getPath();
+        MapboxVectorStyle style = new MapboxVectorStyle(getJson(JsonObject.class,
+          MessageFormat.format(BASE_STYLE, "sprite_test", "\"sprite\":\"file:/" + spritePath + "\"")));
+        assertEquals("file:/" + spritePath, style.getSpriteUrl());
+
+        AtomicBoolean saveFinished = new AtomicBoolean();
+        MainApplication.worker.execute(() -> saveFinished.set(true));
+        Awaitility.await().atMost(Durations.ONE_SECOND).until(saveFinished::get);
+
+        int scalar = 28; // 255 / 9 (could be 4, but this was a nicer number)
+        for (int x = 0; x < 3; x++) {
+            for (int y = 0; y < 3; y++) {
+                // Expected color
+                Color color = new Color(scalar * x, scalar * y, scalar * x * y);
+                int finalX = x;
+                int finalY = y;
+                BufferedImage image = (BufferedImage) assertDoesNotThrow(
+                  () -> ImageProvider.get(new File("test style", MessageFormat.format("({0},{1})", finalX, finalY)).getPath()))
+                  .getImage();
+                assertEquals(3 * hiDpiScalar, image.getWidth(null));
+                assertEquals(3 * hiDpiScalar, image.getHeight(null));
+                for (int x2 = 0; x2 < image.getWidth(null); x2++) {
+                    for (int y2 = 0; y2 < image.getHeight(null); y2++) {
+                        assertEquals(color.getRGB(), image.getRGB(x2, y2));
+                    }
+                }
+            }
+        }
+    }
+
+    private void generateSprites(boolean hiDpi) throws IOException {
+        // Create a 3x3 grid of 3x3 or 6x6 pixel squares (depends upon the dpi setting)
+        int hiDpiScale = hiDpi ? 2 : 1;
+        BufferedImage nineByNine = new BufferedImage(hiDpiScale * 9, hiDpiScale * 9, BufferedImage.TYPE_4BYTE_ABGR);
+        int scalar = 28; // 255 / 9 (could be 4, but this was a nicer number)
+        Graphics2D g = nineByNine.createGraphics();
+        JsonObjectBuilder json = Json.createObjectBuilder();
+        for (int x = 0; x < 3; x++) {
+            for (int y = 0; y < 3; y++) {
+                Color color = new Color(scalar * x, scalar * y, scalar * x * y);
+                g.setColor(color);
+                g.drawRect(3 * hiDpiScale * x, 3 * hiDpiScale * y, 3 * hiDpiScale, 3 * hiDpiScale);
+                g.fillRect(3 * hiDpiScale * x, 3 * hiDpiScale * y, 3 * hiDpiScale, 3 * hiDpiScale);
+
+                JsonObjectBuilder sprite = Json.createObjectBuilder();
+                sprite.add("height", hiDpiScale * 3);
+                sprite.add("pixelRatio", hiDpiScale);
+                sprite.add("width", hiDpiScale * 3);
+                sprite.add("x", 3 * hiDpiScale * x);
+                sprite.add("y", 3 * hiDpiScale * y);
+
+                json.add(MessageFormat.format("({0},{1})", x, y), sprite);
+            }
+        }
+        String imageName = hiDpi ? "sprite@2x.png" : "sprite.png";
+        ImageIO.write(nineByNine, "png", new File(this.spritesDirectory, imageName));
+        String jsonName = hiDpi ? "sprite@2x.json" : "sprite.json";
+        File jsonFile = new File(this.spritesDirectory, jsonName);
+        try (FileOutputStream fileOutputStream = new FileOutputStream(jsonFile)) {
+            fileOutputStream.write(json.build().toString().getBytes(StandardCharsets.UTF_8));
+        }
+    }
+
+    private static <T extends JsonStructure> T getJson(Class<T> clazz, String json) {
+        try (JsonReader reader = Json.createReader(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)))) {
+            JsonStructure structure = reader.read();
+            if (clazz.isAssignableFrom(structure.getClass())) {
+                return clazz.cast(structure);
+            }
+        }
+        fail("Could not cast to expected class");
+        throw new IllegalArgumentException();
+    }
+
+    @Test
+    void testMapillaryStyle() {
+        final String file = Paths.get("file:", TestUtils.getTestDataRoot(), "mapillary.json").toString();
+        final MapboxVectorStyle style = MapboxVectorStyle.getMapboxVectorStyle(file);
+        assertNotNull(style);
+        // There are three "sources" in the mapillary.json file
+        assertEquals(3, style.getSources().size());
+        final ElemStyles mapillarySource = style.getSources().entrySet().stream()
+          .filter(source -> "mapillary-source".equals(source.getKey().getName())).map(
+            Map.Entry::getValue).findAny().orElse(null);
+        assertNotNull(mapillarySource);
+        mapillarySource.getStyleSources().forEach(StyleSource::loadStyleSource);
+        assertEquals(1, mapillarySource.getStyleSources().size());
+        final MapCSSStyleSource mapillaryCssSource = (MapCSSStyleSource) mapillarySource.getStyleSources().get(0);
+        assertTrue(mapillaryCssSource.getErrors().isEmpty());
+        final MapCSSRule mapillaryOverview = getRule(mapillaryCssSource, "node", "mapillary-overview");
+        assertNotNull(mapillaryOverview);
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-shape", new Keyword("circle"));
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-fill-color", ColorHelper.html2color("#05CB63"));
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-fill-opacity", 0.6f);
+        // Docs indicate that symbol-size is total width, while we are translating from a radius. So 2 * 4 = 8.
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-size", 8.0f);
+    }
+
+    @Test
+    void testEqualsContract() {
+        // We need to "load" the style sources to avoid the verifier from thinking they are equal
+        StyleSource canvas = new MapCSSStyleSource("meta{title:\"canvas\";}canvas{default-points:false;}");
+        StyleSource node = new MapCSSStyleSource("meta{title:\"node\";}node{text:ref;}");
+        node.loadStyleSource();
+        canvas.loadStyleSource();
+        EqualsVerifier.forClass(MapboxVectorStyle.class)
+          .withPrefabValues(ImageProvider.class, new ImageProvider("cancel"), new ImageProvider("ok"))
+          .withPrefabValues(StyleSource.class, canvas, node)
+          .usingGetClass().verify();
+    }
+
+    /**
+     * Check that an instruction is in a collection of instructions, and return it
+     * @param instructions The instructions to search
+     * @param key The key to look for
+     * @param value The expected value for the key
+     */
+    private void assertInInstructions(Collection<Instruction> instructions, String key, Object value) {
+        // In JOSM, all Instruction objects are AssignmentInstruction objects
+        Collection<Instruction.AssignmentInstruction> instructionKeys = instructions.stream()
+          .filter(Instruction.AssignmentInstruction.class::isInstance)
+          .map(Instruction.AssignmentInstruction.class::cast).filter(instruction -> Objects.equals(key, instruction.key))
+          .collect(Collectors.toList());
+        Optional<Instruction.AssignmentInstruction> instructionOptional = instructionKeys.stream()
+          .filter(instruction -> Objects.equals(value, instruction.val)).findAny();
+        assertTrue(instructionOptional.isPresent(), MessageFormat
+          .format("Expected {0}, but got {1}", value, instructionOptional.orElse(instructionKeys.stream().findAny()
+            .orElseThrow(() -> new AssertionError("No instruction with "+key+" found"))).val));
+    }
+
+    private static MapCSSRule getRule(MapCSSStyleSource source, String base, String subpart) {
+        // We need to do a new arraylist just to avoid the occasional ConcurrentModificationException
+        return new ArrayList<>(source.rules).stream().filter(rule -> rule.selectors.stream()
+          .anyMatch(selector -> base.equals(selector.getBase()) && subpart.equals(selector.getSubpart().getId(null))))
+          .findAny().orElse(null);
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java	(revision 17862)
@@ -0,0 +1,189 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.data.Bounds;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
+
+/**
+ * Test class for {@link Source}
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class SourceTest {
+    @Test
+    void testEquals() {
+        EqualsVerifier.forClass(Source.class).usingGetClass().verify();
+    }
+
+    @Test
+    void testSimpleSources() {
+        final JsonObject emptyObject = Json.createObjectBuilder().build();
+        assertThrows(NullPointerException.class, () -> new Source("Test source", emptyObject));
+
+        final JsonObject badTypeValue = Json.createObjectBuilder().add("type", "bad type value").build();
+        assertThrows(IllegalArgumentException.class, () -> new Source("Test source", badTypeValue));
+
+        // Only SourceType.{VECTOR,RASTER} are supported
+        final SourceType[] supported = new SourceType[] {SourceType.VECTOR, SourceType.RASTER};
+        for (SourceType type : supported) {
+            final JsonObject goodSourceType = Json.createObjectBuilder().add("type", type.toString().toLowerCase(Locale.ROOT)).build();
+            Source source = assertDoesNotThrow(() -> new Source(type.name(), goodSourceType));
+            // Check defaults
+            assertEquals(0, source.getMinZoom());
+            assertEquals(22, source.getMaxZoom());
+            assertEquals(type.name(), source.getName());
+            assertNull(source.getAttributionText());
+            assertTrue(source.getUrls().isEmpty());
+            assertEquals(new Bounds(-85.051129, -180, 85.051129, 180), source.getBounds());
+        }
+
+        // Check that unsupported types throw
+        for (SourceType type : Stream.of(SourceType.values()).filter(t -> Stream.of(supported).noneMatch(t::equals)).collect(
+          Collectors.toList())) {
+            final JsonObject goodSourceType = Json.createObjectBuilder().add("type", type.toString().toLowerCase(Locale.ROOT)).build();
+            assertThrows(UnsupportedOperationException.class, () -> new Source(type.name(), goodSourceType));
+        }
+    }
+
+    @Test
+    void testTileJsonSpec() {
+        // This isn't currently implemented, so it should throw. Mostly here to remind implementor to add tests...
+        final JsonObject tileJsonSpec = Json.createObjectBuilder()
+          .add("type", SourceType.VECTOR.name()).add("url", "some-random-url.com")
+          .build();
+        assertThrows(InvalidMapboxVectorTileException.class, () -> new Source("Test TileJson", tileJsonSpec));
+    }
+
+    @Test
+    void testBounds() {
+        // Check a "good" bounds
+        final JsonObject tileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("bounds",
+          Json.createArrayBuilder().add(-1).add(-2).add(3).add(4)).build();
+        Source source = new Source("Test Bounds[-1, -2, 3, 4]", tileJsonSpec);
+        assertEquals(new Bounds(-2, -1, 4, 3), source.getBounds());
+
+        // Check "bad" bounds
+        final JsonObject tileJsonSpecShort = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("bounds",
+          Json.createArrayBuilder().add(-1).add(-2).add(3)).build();
+        IllegalArgumentException badLengthException = assertThrows(IllegalArgumentException.class,
+          () -> new Source("Test Bounds[-1, -2, 3]", tileJsonSpecShort));
+        assertEquals("bounds must have four values, but has 3", badLengthException.getMessage());
+
+        final JsonObject tileJsonSpecLong = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("bounds",
+          Json.createArrayBuilder().add(-1).add(-2).add(3).add(4).add(5)).build();
+        badLengthException = assertThrows(IllegalArgumentException.class, () -> new Source("Test Bounds[-1, -2, 3, 4, 5]", tileJsonSpecLong));
+        assertEquals("bounds must have four values, but has 5", badLengthException.getMessage());
+    }
+
+    @Test
+    void testTiles() {
+        // No url
+        final JsonObject tileJsonSpecEmpty = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          JsonValue.NULL).build();
+        Source source = new Source("Test Tile[]", tileJsonSpecEmpty);
+        assertTrue(source.getUrls().isEmpty());
+
+        // Create a tile URL
+        final JsonObject tileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          Json.createArrayBuilder().add("https://example.org/{bbox-epsg-3857}")).build();
+        source = new Source("Test Tile[https://example.org/{bbox-epsg-3857}]", tileJsonSpec);
+        assertEquals(1, source.getUrls().size());
+        // Make certain that {bbox-epsg-3857} is replaced with the JOSM equivalent
+        assertEquals("https://example.org/{bbox}", source.getUrls().get(0));
+
+        // Check with invalid data
+        final JsonObject tileJsonSpecBad = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          Json.createArrayBuilder().add(1).add("https://example.org/{bbox-epsg-3857}").add(false).add(Json.createArrayBuilder().add("hello"))
+            .add(Json.createObjectBuilder().add("bad", "array"))).build();
+        source = new Source("Test Tile[1, https://example.org/{bbox-epsg-3857}, false, [\"hello\"], {\"bad\": \"array\"}]", tileJsonSpecBad);
+        assertEquals(1, source.getUrls().size());
+        // Make certain that {bbox-epsg-3857} is replaced with the JOSM equivalent
+        assertEquals("https://example.org/{bbox}", source.getUrls().get(0));
+    }
+
+    @Test
+    void testZoom() {
+        // Min zoom
+        final JsonObject minZoom5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("minzoom",
+          5).build();
+        Source source = new Source("Test Zoom[minzoom=5]", minZoom5);
+        assertEquals(5, source.getMinZoom());
+        assertEquals(22, source.getMaxZoom());
+
+        // Negative min zoom
+        final JsonObject minZoomNeg1 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("minzoom",
+          -1).build();
+        source = new Source("Test Zoom[minzoom=-1]", minZoomNeg1);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(22, source.getMaxZoom());
+
+        // Max zoom
+        final JsonObject maxZoom5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          5).build();
+        source = new Source("Test Zoom[maxzoom=5]", maxZoom5);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(5, source.getMaxZoom());
+
+        // Big Max zoom
+        final JsonObject maxZoom31 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          31).build();
+        source = new Source("Test Zoom[maxzoom=31]", maxZoom31);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(30, source.getMaxZoom());
+
+        // Negative max zoom
+        final JsonObject maxZoomNeg5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          -5).build();
+        source = new Source("Test Zoom[maxzoom=-5]", maxZoomNeg5);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(0, source.getMaxZoom());
+
+        // Min max zoom
+        final JsonObject minZoom1MaxZoom5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          5).add("minzoom", 1).build();
+        source = new Source("Test Zoom[minzoom=1,maxzoom=5]", minZoom1MaxZoom5);
+        assertEquals(1, source.getMinZoom());
+        assertEquals(5, source.getMaxZoom());
+    }
+
+    @Test
+    void testToString() {
+        // Simple (no urls)
+        final JsonObject noTileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).build();
+        Source source = new Source("Test String[]", noTileJsonSpec);
+        assertEquals("Test String[]", source.toString());
+
+        // With one url
+        final JsonObject tileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          Json.createArrayBuilder().add("https://example.org/{bbox-epsg-3857}")).build();
+        source = new Source("Test String[https://example.org/{bbox-epsg-3857}]", tileJsonSpec);
+        assertEquals("Test String[https://example.org/{bbox-epsg-3857}] https://example.org/{bbox}", source.toString());
+
+        // With two URLs
+        final JsonObject tileJsonSpecMultiple = Json.createObjectBuilder().add("type", SourceType.VECTOR.name())
+          .add("tiles", Json.createArrayBuilder()
+            .add("https://example.org/{bbox-epsg-3857}")
+            .add("https://example.com/{bbox-epsg-3857}")).build();
+        source = new Source("Test String[https://example.org/{bbox-epsg-3857},https://example.com/{bbox-epsg-3857}]", tileJsonSpecMultiple);
+        assertEquals("Test String[https://example.org/{bbox-epsg-3857},https://example.com/{bbox-epsg-3857}] https://example.org/{bbox} "
+          + "https://example.com/{bbox}", source.toString());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java	(revision 17862)
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link ProtobufParser}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ProtobufParserTest {
+    /**
+     * Check that we are appropriately converting values to the "smallest" type
+     */
+    @Test
+    void testConvertLong() {
+        // No casting due to auto conversions
+        assertEquals(Byte.MAX_VALUE, ProtobufParser.convertLong(Byte.MAX_VALUE));
+        assertEquals(Byte.MIN_VALUE, ProtobufParser.convertLong(Byte.MIN_VALUE));
+        assertEquals(Short.MIN_VALUE, ProtobufParser.convertLong(Short.MIN_VALUE));
+        assertEquals(Short.MAX_VALUE, ProtobufParser.convertLong(Short.MAX_VALUE));
+        assertEquals(Integer.MAX_VALUE, ProtobufParser.convertLong(Integer.MAX_VALUE));
+        assertEquals(Integer.MIN_VALUE, ProtobufParser.convertLong(Integer.MIN_VALUE));
+        assertEquals(Long.MIN_VALUE, ProtobufParser.convertLong(Long.MIN_VALUE));
+        assertEquals(Long.MAX_VALUE, ProtobufParser.convertLong(Long.MAX_VALUE));
+    }
+
+    /**
+     * Check that zig zags are appropriately encoded.
+     */
+    @Test
+    void testEncodeZigZag() {
+        assertEquals(0, ProtobufParser.encodeZigZag(0).byteValue());
+        assertEquals(1, ProtobufParser.encodeZigZag(-1).byteValue());
+        assertEquals(2, ProtobufParser.encodeZigZag(1).byteValue());
+        assertEquals(3, ProtobufParser.encodeZigZag(-2).byteValue());
+        assertEquals(254, ProtobufParser.encodeZigZag(Byte.MAX_VALUE).shortValue());
+        assertEquals(255, ProtobufParser.encodeZigZag(Byte.MIN_VALUE).shortValue());
+        assertEquals(65_534, ProtobufParser.encodeZigZag(Short.MAX_VALUE).intValue());
+        assertEquals(65_535, ProtobufParser.encodeZigZag(Short.MIN_VALUE).intValue());
+        // These integers check a possible boundary condition (the boundary between using the 32/64 bit encoding methods)
+        assertEquals(4_294_967_292L, ProtobufParser.encodeZigZag(Integer.MAX_VALUE - 1).longValue());
+        assertEquals(4_294_967_293L, ProtobufParser.encodeZigZag(Integer.MIN_VALUE + 1).longValue());
+        assertEquals(4_294_967_294L, ProtobufParser.encodeZigZag(Integer.MAX_VALUE).longValue());
+        assertEquals(4_294_967_295L, ProtobufParser.encodeZigZag(Integer.MIN_VALUE).longValue());
+        assertEquals(4_294_967_296L, ProtobufParser.encodeZigZag(Integer.MAX_VALUE + 1L).longValue());
+        assertEquals(4_294_967_297L, ProtobufParser.encodeZigZag(Integer.MIN_VALUE - 1L).longValue());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java	(revision 17862)
@@ -0,0 +1,30 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for specific {@link ProtobufRecord} functionality
+ */
+class ProtobufRecordTest {
+    @Test
+    void testFixed32() throws IOException {
+        ProtobufParser parser = new ProtobufParser(ProtobufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
+        ProtobufRecord thirtyTwoBit = new ProtobufRecord(parser);
+        assertEquals(WireType.THIRTY_TWO_BIT, thirtyTwoBit.getType());
+        assertEquals(1f, thirtyTwoBit.asFloat());
+    }
+
+    @Test
+    void testUnknown() throws IOException {
+        ProtobufParser parser = new ProtobufParser(ProtobufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
+        ProtobufRecord unknown = new ProtobufRecord(parser);
+        assertEquals(WireType.UNKNOWN, unknown.getType());
+        assertEquals(0, unknown.getBytes().length);
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java	(revision 17862)
@@ -0,0 +1,211 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.awt.geom.Ellipse2D;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Paths;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Feature;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+import org.openstreetmap.josm.data.vector.VectorNode;
+import org.openstreetmap.josm.data.vector.VectorWay;
+import org.openstreetmap.josm.io.Compression;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * Test class for {@link ProtobufParser} and {@link ProtobufRecord}
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ProtobufTest {
+    /**
+     * Convert an int array into a byte array
+     * @param intArray The int array to convert (NOTE: numbers must be below 255)
+     * @return A byte array that can be used
+     */
+    static byte[] toByteArray(int[] intArray) {
+        byte[] byteArray = new byte[intArray.length];
+        for (int i = 0; i < intArray.length; i++) {
+            if (intArray[i] > Byte.MAX_VALUE - Byte.MIN_VALUE) {
+                throw new IllegalArgumentException();
+            }
+            byteArray[i] = Integer.valueOf(intArray[i]).byteValue();
+        }
+        return byteArray;
+    }
+
+    @RegisterExtension
+    JOSMTestRules josmTestRules = new JOSMTestRules().preferences();
+
+    private Number bytesToVarInt(int... bytes) {
+        byte[] byteArray = new byte[bytes.length];
+        for (int i = 0; i < bytes.length; i++) {
+            byteArray[i] = (byte) bytes[i];
+        }
+        return ProtobufParser.convertByteArray(byteArray, ProtobufParser.VAR_INT_BYTE_SIZE);
+    }
+
+    /**
+     * Test reading tile from Mapillary ( 14/3248/6258 )
+     *
+     * @throws IOException if there is a problem reading the file
+     */
+    @Test
+    void testRead_14_3248_6258() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "mapillary", "14", "3248", "6258.mvt").toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtobufRecord> records = new ProtobufParser(inputStream).allRecords();
+        assertEquals(2, records.size());
+        List<Layer> layers = new ArrayList<>();
+        for (ProtobufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        Layer mapillarySequences = layers.get(0);
+        Layer mapillaryPictures = layers.get(1);
+        assertEquals("mapillary-sequences", mapillarySequences.getName());
+        assertEquals("mapillary-images", mapillaryPictures.getName());
+        assertEquals(4096, mapillarySequences.getExtent());
+        assertEquals(4096, mapillaryPictures.getExtent());
+
+        assertEquals(1,
+                mapillarySequences.getFeatures().stream().filter(feature -> feature.getId() == 233760500).count());
+        Feature testSequence = mapillarySequences.getFeatures().stream().filter(feature -> feature.getId() == 233760500)
+                .findAny().orElse(null);
+        assertEquals("dpudn262yz6aitu33zh7bl", testSequence.getTags().get("key"));
+        assertEquals("clnaw3kpokIAe_CsN5Qmiw", testSequence.getTags().get("ikey"));
+        assertEquals("B1iNjH4Ohn25cRAGPhetfw", testSequence.getTags().get("userkey"));
+        assertEquals(Long.valueOf(1557535457401L), Long.valueOf(testSequence.getTags().get("captured_at")));
+        assertEquals(0, Integer.parseInt(testSequence.getTags().get("pano")));
+    }
+
+    @Test
+    void testRead_17_26028_50060() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "openinframap", "17", "26028", "50060.pbf")
+                .toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtobufRecord> records = new ProtobufParser(inputStream).allRecords();
+        List<Layer> layers = new ArrayList<>();
+        for (ProtobufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        assertEquals(19, layers.size());
+        List<Layer> dataLayers = layers.stream().filter(layer -> !layer.getFeatures().isEmpty())
+                .collect(Collectors.toList());
+        // power_plant, power_plant_point, power_generator, power_heatmap_solar, and power_generator_area
+        assertEquals(5, dataLayers.size());
+
+        // power_generator_area was rendered incorrectly
+        final Layer powerGeneratorArea = dataLayers.stream()
+                .filter(layer -> "power_generator_area".equals(layer.getName())).findAny().orElse(null);
+        assertNotNull(powerGeneratorArea);
+        final int extent = powerGeneratorArea.getExtent();
+        // 17/26028/50060 bounds
+        VectorDataSet vectorDataSet = new VectorDataSet();
+        MVTTile vectorTile1 = new MVTTile(new MapboxVectorTileSource(new ImageryInfo("Test info", "example.org")),
+                26028, 50060, 17);
+        vectorTile1.loadImage(Compression.getUncompressedFileInputStream(vectorTile));
+        vectorDataSet.addTileData(vectorTile1);
+        vectorDataSet.setZoom(17);
+        final Way one = new Way();
+        one.addNode(new Node(new LatLon(39.0687509, -108.5100816)));
+        one.addNode(new Node(new LatLon(39.0687509, -108.5095751)));
+        one.addNode(new Node(new LatLon(39.0687169, -108.5095751)));
+        one.addNode(new Node(new LatLon(39.0687169, -108.5100816)));
+        one.addNode(one.getNode(0));
+        one.setOsmId(666293899, 2);
+        final BBox searchBBox = one.getBBox();
+        searchBBox.addPrimitive(one, 0.00001);
+        final Collection<VectorNode> searchedNodes = vectorDataSet.searchNodes(searchBBox);
+        final Collection<VectorWay> searchedWays = vectorDataSet.searchWays(searchBBox);
+        assertEquals(4, searchedNodes.size());
+    }
+
+    @Test
+    void testReadVarInt() {
+        assertEquals(ProtobufParser.convertLong(0), bytesToVarInt(0x0));
+        assertEquals(ProtobufParser.convertLong(1), bytesToVarInt(0x1));
+        assertEquals(ProtobufParser.convertLong(127), bytesToVarInt(0x7f));
+        // This should b 0xff 0xff 0xff 0xff 0x07, but we drop the leading bit when reading to a byte array
+        Number actual = bytesToVarInt(0x7f, 0x7f, 0x7f, 0x7f, 0x07);
+        assertEquals(ProtobufParser.convertLong(Integer.MAX_VALUE), actual,
+                MessageFormat.format("Expected {0} but got {1}", Integer.toBinaryString(Integer.MAX_VALUE),
+                        Long.toBinaryString(actual.longValue())));
+    }
+
+    /**
+     * Test simple message.
+     * Check that a simple message is readable
+     *
+     * @throws IOException - if an IO error occurs
+     */
+    @Test
+    void testSimpleMessage() throws IOException {
+        ProtobufParser parser = new ProtobufParser(new byte[] {(byte) 0x08, (byte) 0x96, (byte) 0x01});
+        ProtobufRecord record = new ProtobufRecord(parser);
+        assertEquals(WireType.VARINT, record.getType());
+        assertEquals(150, record.asUnsignedVarInt().intValue());
+    }
+
+    @Test
+    void testSingletonMultiPoint() throws IOException {
+        Collection<ProtobufRecord> records = new ProtobufParser(new ByteArrayInputStream(toByteArray(
+                new int[] {0x1a, 0x2c, 0x78, 0x02, 0x0a, 0x03, 0x74, 0x6d, 0x70, 0x28, 0x80, 0x20, 0x1a, 0x04, 0x6e,
+                        0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x54, 0x65, 0x73, 0x74, 0x20, 0x6e, 0x61, 0x6d, 0x65,
+                        0x12, 0x0d, 0x18, 0x01, 0x12, 0x02, 0x00, 0x00, 0x22, 0x05, 0x09, 0xe0, 0x3e, 0x84, 0x27})))
+                                .allRecords();
+        List<Layer> layers = new ArrayList<>();
+        for (ProtobufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        assertEquals(1, layers.size());
+        assertEquals(1, layers.get(0).getGeometry().size());
+        Ellipse2D shape = (Ellipse2D) layers.get(0).getGeometry().iterator().next().getShapes().iterator().next();
+        assertEquals(4016, shape.getCenterX());
+        assertEquals(2498, shape.getCenterY());
+    }
+
+    @Test
+    void testZigZag() {
+        assertEquals(0, ProtobufParser.decodeZigZag(0).intValue());
+        assertEquals(-1, ProtobufParser.decodeZigZag(1).intValue());
+        assertEquals(1, ProtobufParser.decodeZigZag(2).intValue());
+        assertEquals(-2, ProtobufParser.decodeZigZag(3).intValue());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java	(revision 17862)
@@ -0,0 +1,126 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
+import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A test for {@link VectorDataSet}
+ */
+class VectorDataSetTest {
+    /**
+     * Make some methods available for this test class
+     */
+    private static class MVTLayerMock extends MVTLayer {
+        private final Collection<MVTTile> finishedLoading = new HashSet<>();
+
+        MVTLayerMock(ImageryInfo info) {
+            super(info);
+        }
+
+        @Override
+        protected MapboxVectorTileSource getTileSource() {
+            return super.getTileSource();
+        }
+
+        protected MapboxVectorCachedTileLoader getTileLoader() {
+            if (this.tileLoader == null) {
+                this.tileLoader = this.getTileLoaderFactory().makeTileLoader(this, Collections.emptyMap(), 7200);
+            }
+            if (this.tileLoader instanceof MapboxVectorCachedTileLoader) {
+                return (MapboxVectorCachedTileLoader) this.tileLoader;
+            }
+            return null;
+        }
+
+        @Override
+        public void finishedLoading(MVTTile tile) {
+            super.finishedLoading(tile);
+            this.finishedLoading.add(tile);
+        }
+
+        public Collection<MVTTile> finishedLoading() {
+            return this.finishedLoading;
+        }
+    }
+
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules().projection();
+
+    /**
+     * Load arbitrary tiles
+     * @param layer The layer to add the tiles to
+     * @param tiles The tiles to load ([z, x, y, z, x, y, ...]) -- must be divisible by three
+     */
+    private static void loadTile(MVTLayerMock layer, int... tiles) {
+        if (tiles.length % 3 != 0 || tiles.length == 0) {
+            throw new IllegalArgumentException("Tiles come with a {z}, {x}, and {y} component");
+        }
+        final MapboxVectorTileSource tileSource = layer.getTileSource();
+        MapboxVectorCachedTileLoader tileLoader = layer.getTileLoader();
+        Collection<MVTTile> tilesCollection = new ArrayList<>();
+        for (int i = 0; i < tiles.length / 3; i++) {
+            final MVTTile tile = (MVTTile) layer.createTile(tileSource, tiles[3 * i + 1], tiles[3 * i + 2], tiles[3 * i]);
+            tileLoader.createTileLoaderJob(tile).submit();
+            tilesCollection.add(tile);
+        }
+        Awaitility.await().atMost(Durations.FIVE_SECONDS).until(() -> layer.finishedLoading().size() == tilesCollection
+          .size());
+    }
+
+    private MVTLayerMock layer;
+
+    @BeforeEach
+    void setup() {
+        // Create the preconditions for the test
+        final ImageryInfo info = new ImageryInfo();
+        info.setName("en", "Test info");
+        info.setUrl("file:/" + Paths.get(TestUtils.getTestDataRoot(), "pbf", "mapillary", "{z}", "{x}", "{y}.mvt"));
+        layer = new MVTLayerMock(info);
+    }
+
+    @Test
+    void testNodeDeduplication() {
+        final VectorDataSet dataSet = this.layer.getData();
+        assertTrue(dataSet.allPrimitives().isEmpty());
+
+        // Set the zoom to 14, as that is the tile we are checking
+        dataSet.setZoom(14);
+        loadTile(this.layer, 14, 3248, 6258);
+
+        // Actual test
+        // With Mapillary, only ends of ways should be untagged
+        // There are 55 actual "nodes" in the data with two nodes for the ends of the way.
+        // One of the end nodes is a duplicate of an actual node.
+        assertEquals(56, dataSet.getNodes().size());
+        // There should be 55 nodes from the mapillary-images layer
+        assertEquals(55, dataSet.getNodes().stream().filter(node -> "mapillary-images".equals(node.getLayer())).count());
+        // Please note that this dataset originally had the <i>same</i> id for all the images
+        // (MVT v2 explicitly said that ids had to be unique in a layer, MVT v1 did not)
+        // This number is from the 56 nodes - original node with id - single node on mapillary-sequences layer = 54
+        assertEquals(54, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).count());
+        assertEquals(1, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).map(node -> node.get("original_id"))
+            .distinct().count());
+        assertEquals(1, dataSet.getWays().size());
+        assertEquals(0, dataSet.getRelations().size());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java	(revision 17862)
@@ -0,0 +1,153 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Test class for {@link VectorNode}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorNodeTest {
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules().projection();
+
+    @Test
+    void testLatLon() {
+        VectorNode node = new VectorNode("test");
+        assertTrue(Double.isNaN(node.lat()));
+        assertTrue(Double.isNaN(node.lon()));
+        LatLon testLatLon = new LatLon(50, -40);
+        node.setCoor(testLatLon);
+        assertEquals(50, node.lat());
+        assertEquals(-40, node.lon());
+        assertEquals(testLatLon, node.getCoor());
+    }
+
+    @Test
+    void testSetEastNorth() {
+        VectorNode node = new VectorNode("test");
+        LatLon latLon = new LatLon(-1, 5);
+        EastNorth eastNorth = ProjectionRegistry.getProjection().latlon2eastNorth(latLon);
+        node.setEastNorth(eastNorth);
+        assertEquals(-1, node.lat(), 0.0000000001);
+        assertEquals(5, node.lon(), 0.0000000001);
+    }
+
+    @Test
+    void testICoordinate() {
+        VectorNode node = new VectorNode("test");
+        assertTrue(Double.isNaN(node.lat()));
+        assertTrue(Double.isNaN(node.lon()));
+        ICoordinate coord = new ICoordinate() {
+            @Override
+            public double getLat() {
+                return 5;
+            }
+
+            @Override
+            public void setLat(double lat) {
+                // No op
+            }
+
+            @Override
+            public double getLon() {
+                return -1;
+            }
+
+            @Override
+            public void setLon(double lon) {
+                // no op
+            }
+        };
+        node.setCoor(coord);
+        assertEquals(5, node.lat());
+        assertEquals(-1, node.lon());
+    }
+
+    @Test
+    void testUniqueIdGenerator() {
+        VectorNode node1 = new VectorNode("test");
+        VectorNode node2 = new VectorNode("test2");
+        assertSame(node1.getIdGenerator(), node2.getIdGenerator());
+        assertNotNull(node1.getIdGenerator());
+    }
+
+    @Test
+    void testNode() {
+        assertEquals(OsmPrimitiveType.NODE, new VectorNode("test").getType());
+    }
+
+    @Test
+    void testBBox() {
+        VectorNode node = new VectorNode("test");
+        node.setCoor(new LatLon(5, -1));
+        assertTrue(node.getBBox().bboxIsFunctionallyEqual(new BBox(-1, 5), 0d));
+    }
+
+    @Test
+    void testVisitor() {
+        List<VectorNode> visited = new ArrayList<>();
+        VectorNode node = new VectorNode("test");
+        node.accept(new PrimitiveVisitor() {
+            @Override
+            public void visit(INode n) {
+                visited.add((VectorNode) n);
+            }
+
+            @Override
+            public void visit(IWay<?> w) {
+                fail("Way should not have been visited");
+            }
+
+            @Override
+            public void visit(IRelation<?> r) {
+                fail("Relation should not have been visited");
+            }
+        });
+
+        assertEquals(1, visited.size());
+        assertSame(node, visited.get(0));
+    }
+
+    @Test
+    void testIsReferredToByWays() {
+        VectorWay way = new VectorWay("test");
+        VectorNode node = new VectorNode("test");
+        assertFalse(node.isReferredByWays(1));
+        assertTrue(node.getReferrers(true).isEmpty());
+        way.setNodes(Collections.singletonList(node));
+        assertEquals(1, node.getReferrers(true).size());
+        assertSame(way, node.getReferrers(true).get(0));
+        // No dataset yet
+        assertFalse(node.isReferredByWays(1));
+        VectorDataSet dataSet = new VectorDataSet();
+        dataSet.addPrimitive(way);
+        dataSet.addPrimitive(node);
+        assertTrue(node.isReferredByWays(1));
+        assertFalse(node.isReferredByWays(2));
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java	(revision 17862)
@@ -0,0 +1,45 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test class for {@link VectorRelation}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorRelationTest {
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules();
+
+    @Test
+    void testMembers() {
+        VectorNode node1 = new VectorNode("test");
+        VectorNode node2 = new VectorNode("test");
+        VectorWay way1 = new VectorWay("test");
+        way1.setNodes(Arrays.asList(node1, node2));
+        VectorRelationMember member1 = new VectorRelationMember("randomRole", node1);
+        VectorRelationMember member2 = new VectorRelationMember("role2", way1);
+        assertSame(node1, member1.getMember());
+        assertSame(node1.getType(), member1.getType());
+        assertEquals("randomRole", member1.getRole());
+        assertSame(node1.getId(), member1.getUniqueId());
+        // Not a way.
+        assertThrows(ClassCastException.class, member1::getWay);
+
+        assertTrue(member1.isNode());
+        assertFalse(member1.isWay());
+        assertFalse(member2.isNode());
+        assertTrue(member2.isWay());
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java	(revision 17862)
+++ trunk/test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java	(revision 17862)
@@ -0,0 +1,117 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Test class for {@link VectorWay}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorWayTest {
+    @Test
+    void testBBox() {
+        VectorNode node1 = new VectorNode("test");
+        VectorWay way = new VectorWay("test");
+        way.setNodes(Collections.singletonList(node1));
+        node1.setCoor(new LatLon(-5, 1));
+        assertTrue(node1.getBBox().bboxIsFunctionallyEqual(way.getBBox(), 0.0));
+
+        VectorNode node2 = new VectorNode("test");
+        node2.setCoor(new LatLon(-10, 2));
+
+        way.setNodes(Arrays.asList(node1, node2));
+        assertTrue(way.getBBox().bboxIsFunctionallyEqual(new BBox(2, -10, 1, -5), 0.0));
+    }
+
+    @Test
+    void testIdGenerator() {
+        assertSame(new VectorWay("test").getIdGenerator(), new VectorWay("test").getIdGenerator());
+    }
+
+    @Test
+    void testNodes() {
+        VectorNode node1 = new VectorNode("test");
+        VectorNode node2 = new VectorNode("test");
+        VectorNode node3 = new VectorNode("test");
+        node1.setId(1);
+        node2.setId(2);
+        node3.setId(3);
+        VectorWay way = new VectorWay("test");
+        assertNull(way.firstNode());
+        assertNull(way.lastNode());
+        assertFalse(way.isClosed());
+        assertFalse(way.isFirstLastNode(node1));
+        assertFalse(way.isInnerNode(node2));
+        way.setNodes(Arrays.asList(node1, node2, node3));
+        assertEquals(3, way.getNodesCount());
+        assertEquals(node1, way.getNode(0));
+        assertEquals(node2, way.getNode(1));
+        assertEquals(node3, way.getNode(2));
+        assertTrue(way.isFirstLastNode(node1));
+        assertTrue(way.isFirstLastNode(node3));
+        assertFalse(way.isFirstLastNode(node2));
+        assertTrue(way.isInnerNode(node2));
+        assertFalse(way.isInnerNode(node1));
+        assertFalse(way.isInnerNode(node3));
+
+        assertEquals(1, way.getNodeIds().get(0));
+        assertEquals(2, way.getNodeIds().get(1));
+        assertEquals(3, way.getNodeIds().get(2));
+        assertEquals(1, way.getNodeId(0));
+        assertEquals(2, way.getNodeId(1));
+        assertEquals(3, way.getNodeId(2));
+
+        assertFalse(way.isClosed());
+        assertEquals(OsmPrimitiveType.WAY, way.getType());
+        List<VectorNode> nodes = new ArrayList<>(way.getNodes());
+        nodes.add(nodes.get(0));
+        way.setNodes(nodes);
+        assertTrue(way.isClosed());
+        assertEquals(OsmPrimitiveType.CLOSEDWAY, way.getType());
+    }
+
+    @Test
+    void testAccept() {
+        VectorWay way = new VectorWay("test");
+        List<VectorWay> visited = new ArrayList<>(1);
+        way.accept(new PrimitiveVisitor() {
+            @Override
+            public void visit(INode n) {
+                fail("No nodes should be visited");
+            }
+
+            @Override
+            public void visit(IWay<?> w) {
+                visited.add((VectorWay) w);
+            }
+
+            @Override
+            public void visit(IRelation<?> r) {
+                fail("No relations should be visited");
+            }
+        });
+
+        assertEquals(1, visited.size());
+        assertSame(way, visited.get(0));
+    }
+}
