package com.onthegomap.planetiler.pmtiles; import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.databind.ObjectMapper; import com.onthegomap.planetiler.Profile; import com.onthegomap.planetiler.TestUtils; import com.onthegomap.planetiler.archive.TileArchiveMetadata; import com.onthegomap.planetiler.archive.TileEncodingResult; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.reader.FileFormatException; import com.onthegomap.planetiler.util.LayerStats; import com.onthegomap.planetiler.util.SeekableInMemoryByteChannel; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.OptionalLong; import java.util.Set; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Envelope; class PmtilesTest { @Test void testRoundtripHeader() { byte specVersion = 3; long rootDirOffset = 1; long rootDirLength = 2; long jsonMetadataOffset = 3; long jsonMetadataLength = 4; long leafDirectoriesOffset = 5; long leafDirectoriesLength = 6; long tileDataOffset = 7; long tileDataLength = 8; long numAddressedTiles = 9; long numTileEntries = 10; long numTileContents = 11; boolean clustered = true; Pmtiles.Compression internalCompression = Pmtiles.Compression.GZIP; Pmtiles.Compression tileCompression = Pmtiles.Compression.GZIP; Pmtiles.TileType tileType = Pmtiles.TileType.MVT; byte minZoom = 1; byte maxZoom = 3; int minLonE7 = -10_000_000; int minLatE7 = -20_000_000; int maxLonE7 = 10_000_000; int maxLatE7 = 20_000_000; byte centerZoom = 2; int centerLonE7 = -5_000_000; int centerLatE7 = -6_000_000; Pmtiles.Header in = new Pmtiles.Header( specVersion, rootDirOffset, rootDirLength, jsonMetadataOffset, jsonMetadataLength, leafDirectoriesOffset, leafDirectoriesLength, tileDataOffset, tileDataLength, numAddressedTiles, numTileEntries, numTileContents, clustered, internalCompression, tileCompression, tileType, minZoom, maxZoom, minLonE7, minLatE7, maxLonE7, maxLatE7, centerZoom, centerLonE7, centerLatE7 ); Pmtiles.Header out = Pmtiles.Header.fromBytes(in.toBytes()); assertEquals(specVersion, out.specVersion()); assertEquals(rootDirOffset, out.rootDirOffset()); assertEquals(rootDirLength, out.rootDirLength()); assertEquals(jsonMetadataOffset, out.jsonMetadataOffset()); assertEquals(jsonMetadataLength, out.jsonMetadataLength()); assertEquals(leafDirectoriesOffset, out.leafDirectoriesOffset()); assertEquals(leafDirectoriesLength, out.leafDirectoriesLength()); assertEquals(tileDataOffset, out.tileDataOffset()); assertEquals(tileDataLength, out.tileDataLength()); assertEquals(numAddressedTiles, out.numAddressedTiles()); assertEquals(numTileEntries, out.numTileEntries()); assertEquals(numTileContents, out.numTileContents()); assertEquals(clustered, out.clustered()); assertEquals(internalCompression, out.internalCompression()); assertEquals(tileCompression, out.tileCompression()); assertEquals(tileType, out.tileType()); assertEquals(minZoom, out.minZoom()); assertEquals(maxZoom, out.maxZoom()); assertEquals(minLonE7, out.minLonE7()); assertEquals(minLatE7, out.minLatE7()); assertEquals(maxLonE7, out.maxLonE7()); assertEquals(maxLatE7, out.maxLatE7()); assertEquals(centerZoom, out.centerZoom()); assertEquals(centerLonE7, out.centerLonE7()); assertEquals(centerLatE7, out.centerLatE7()); } @Test void testBadHeader() { assertThrows(FileFormatException.class, () -> Pmtiles.Header.fromBytes(new byte[0])); assertThrows(FileFormatException.class, () -> Pmtiles.Header.fromBytes(new byte[127])); } @Test void testRoundtripDirectoryMinimal() { ArrayList in = new ArrayList<>(); in.add(new Pmtiles.Entry(0, 0, 1, 1)); List out = Pmtiles.directoryFromBytes(Pmtiles.directoryToBytes(in)); assertEquals(in, out); } @Test void testRoundtripDirectorySimple() { ArrayList in = new ArrayList<>(); // make sure there are cases of contiguous entries and non-contiguous entries. in.add(new Pmtiles.Entry(0, 0, 1, 0)); in.add(new Pmtiles.Entry(1, 1, 1, 1)); in.add(new Pmtiles.Entry(2, 3, 1, 1)); List out = Pmtiles.directoryFromBytes(Pmtiles.directoryToBytes(in)); assertEquals(in, out); out = Pmtiles.directoryFromBytes(Pmtiles.directoryToBytes(in, 0, in.size())); assertEquals(in, out); } @Test void testRoundtripDirectorySlice() { ArrayList in = new ArrayList<>(); // make sure there are cases of contiguous entries and non-contiguous entries. in.add(new Pmtiles.Entry(0, 0, 1, 0)); in.add(new Pmtiles.Entry(1, 1, 1, 1)); in.add(new Pmtiles.Entry(2, 3, 1, 1)); List out = Pmtiles.directoryFromBytes( Pmtiles.directoryToBytes(in, 1, 2)); assertEquals(1, out.size()); } @Test void testBuildDirectoriesRootOnly() throws IOException { ArrayList in = new ArrayList<>(); for (int i = 0; i < 1000; i++) { in.add(new Pmtiles.Entry(i, i * 100, 100, 1)); } var result = WriteablePmtiles.makeDirectories(in); assertEquals(1, result.numAttempts()); } @Test void testBuildDirectoriesLeavesNotTooSmall() throws IOException { ArrayList in = new ArrayList<>(); for (int i = 0; i < 100000; i++) { in.add(new Pmtiles.Entry(i, i * 100, 100, 1)); } var result = WriteablePmtiles.makeDirectories(in); assertTrue(result.leafSize() >= 4096, "entries in leaf: " + result.leafSize()); } @Test void testWritePmtilesSingleEntry() throws IOException { var bytes = new SeekableInMemoryByteChannel(0); var in = WriteablePmtiles.newWriteToMemory(bytes); var config = PlanetilerConfig.defaults(); var metadata = new TileArchiveMetadata(new Profile.NullProfile(), config); in.initialize(metadata); var writer = in.newTileWriter(); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 1), new byte[]{0xa, 0x2}, OptionalLong.empty())); in.finish(metadata); var reader = new ReadablePmtiles(bytes); var header = reader.getHeader(); assertEquals(1, header.numAddressedTiles()); assertEquals(1, header.numTileContents()); assertEquals(1, header.numTileEntries()); assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 1)); assertNull(reader.getTile(0, 0, 0)); assertNull(reader.getTile(0, 0, 2)); Set coordset = reader.getAllTileCoords().stream().collect(Collectors.toSet()); assertEquals(1, coordset.size()); } @Test void testRoundtripMetadata() throws IOException { roundTripMetadata(new TileArchiveMetadata( "MyName", "MyDescription", "MyAttribution", "MyVersion", "baselayer", TileArchiveMetadata.MVT_FORMAT, new Envelope(1, 2, 3, 4), new CoordinateXY(5, 6), 7d, 8, 9, List.of(new LayerStats.VectorLayer("MyLayer", Map.of())), Map.of("other key", "other value") )); } @Test void testRoundtripMetadataMinimal() throws IOException { roundTripMetadata( new TileArchiveMetadata(null, null, null, null, null, null, null, null, null, null, null, null, Map.of()), new TileArchiveMetadata(null, null, null, null, null, null, new Envelope(-180, 180, -85.0511287, 85.0511287), new CoordinateXY(0, 0), 0d, 0, 15, null, Map.of() ) ); } private static void roundTripMetadata(TileArchiveMetadata metadata) throws IOException { roundTripMetadata(metadata, metadata); } private static void roundTripMetadata(TileArchiveMetadata input, TileArchiveMetadata output) throws IOException { try ( var channel = new SeekableInMemoryByteChannel(0); var in = WriteablePmtiles.newWriteToMemory(channel) ) { in.initialize(input); var writer = in.newTileWriter(); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 0), new byte[]{0xa, 0x2}, OptionalLong.empty())); in.finish(input); var reader = new ReadablePmtiles(channel); assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 0)); assertEquals(output, reader.metadata()); } } @Test void testPmtilesMetadataTopLevelKeys() throws IOException { var hashMap = new HashMap(); hashMap.put("testkey", "testvalue"); var metadata = new Pmtiles.JsonMetadata(List.of(), hashMap); var bytes = metadata.toBytes(); ObjectMapper mapper = new ObjectMapper(); var node = mapper.readTree(bytes); assertEquals("testvalue", node.get("testkey").asText()); } @Test void testReadPmtilesFromTippecanoe() throws IOException { // '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o box1degree.pmtiles var reader = new ReadablePmtiles(FileChannel.open(TestUtils.pathToResource("box1degree.pmtiles"))); var header = reader.getHeader(); assertTrue(header.maxZoom() <= 15); assertNotNull(reader.getTile(0, 0, 0)); } @Test void testWritePmtilesDuplication() throws IOException { var bytes = new SeekableInMemoryByteChannel(0); var in = WriteablePmtiles.newWriteToMemory(bytes); var config = PlanetilerConfig.defaults(); var metadata = new TileArchiveMetadata(new Profile.NullProfile(), config); in.initialize(metadata); var writer = in.newTileWriter(); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 0), new byte[]{0xa, 0x2}, OptionalLong.of(42))); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 1), new byte[]{0xa, 0x2}, OptionalLong.of(42))); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 2), new byte[]{0xa, 0x2}, OptionalLong.of(42))); in.finish(metadata); var reader = new ReadablePmtiles(bytes); var header = reader.getHeader(); assertEquals(3, header.numAddressedTiles()); assertEquals(1, header.numTileContents()); assertEquals(2, header.numTileEntries()); // z0 and z1 are contiguous assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 0)); assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 1)); assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 2)); Set coordset = reader.getAllTileCoords().stream().collect(Collectors.toSet()); assertEquals(3, coordset.size()); } @Test void testWritePmtilesUnclustered() throws IOException { var bytes = new SeekableInMemoryByteChannel(0); var in = WriteablePmtiles.newWriteToMemory(bytes); var config = PlanetilerConfig.defaults(); var metadata = new TileArchiveMetadata(new Profile.NullProfile(), config); in.initialize(metadata); var writer = in.newTileWriter(); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 1), new byte[]{0xa, 0x2}, OptionalLong.of(42))); writer.write(new TileEncodingResult(TileCoord.ofXYZ(0, 0, 0), new byte[]{0xa, 0x2}, OptionalLong.of(42))); in.finish(metadata); var reader = new ReadablePmtiles(bytes); var header = reader.getHeader(); assertEquals(2, header.numAddressedTiles()); assertEquals(1, header.numTileContents()); assertEquals(2, header.numTileEntries()); assertFalse(header.clustered()); assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 0)); assertArrayEquals(new byte[]{0xa, 0x2}, reader.getTile(0, 0, 1)); Set coordset = reader.getAllTileCoords().stream().collect(Collectors.toSet()); assertEquals(2, coordset.size()); } @Test void testWritePmtilesLeafDirectories() throws IOException { var bytes = new SeekableInMemoryByteChannel(0); var in = WriteablePmtiles.newWriteToMemory(bytes); var config = PlanetilerConfig.defaults(); var metadata = new TileArchiveMetadata(new Profile.NullProfile(), config); in.initialize(metadata); var writer = in.newTileWriter(); int ENTRIES = 20000; for (int i = 0; i < ENTRIES; i++) { writer.write(new TileEncodingResult(TileCoord.hilbertDecode(i), ByteBuffer.allocate(4).putInt(i).array(), OptionalLong.empty())); } in.finish(metadata); var reader = new ReadablePmtiles(bytes); var header = reader.getHeader(); assertEquals(ENTRIES, header.numAddressedTiles()); assertEquals(ENTRIES, header.numTileContents()); assertEquals(ENTRIES, header.numTileEntries()); assertTrue(header.leafDirectoriesLength() > 0); for (int i = 0; i < ENTRIES; i++) { var coord = TileCoord.hilbertDecode(i); assertArrayEquals(ByteBuffer.allocate(4).putInt(i).array(), reader.getTile(coord.x(), coord.y(), coord.z()), "tileCoord=%s did not match".formatted(coord.toString())); } Set coordset = reader.getAllTileCoords().stream().collect(Collectors.toSet()); assertEquals(ENTRIES, coordset.size()); for (int i = 0; i < ENTRIES; i++) { var coord = TileCoord.hilbertDecode(i); assertTrue(coordset.contains(coord), "tileCoord=%s not in result".formatted(coord.toString())); } } }