oxedyne/fe2o3/fe2o3_steel/tests/tiles.rs
16.7 KiB, 11 runs
created by r1870400018:59925, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The tile route: what it answers, what it will not answer, and what it cannot read. |
| 2 | //! |
| 3 | //! The archive here is held in memory behind the same `TileArchive` interface the PMTiles reader |
| 4 | //! will implement, so everything the route decides is exercised without an archive file. Requests |
| 5 | //! go through the real wire parser, because the privacy claims are about bytes that arrived from |
| 6 | //! outside, cookies included. |
| 7 | //! |
| 8 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 9 | //! Anthropic Claude |
| 10 | |
| 11 | use oxedyne_fe2o3_core::prelude::*; |
| 12 | use oxedyne_fe2o3_jdat::prelude::*; |
| 13 | use oxedyne_fe2o3_net::http::{ |
| 14 | encoding::{ |
| 15 | self, |
| 16 | ContentCoding, |
| 17 | }, |
| 18 | msg::HttpMessage, |
| 19 | }; |
| 20 | use oxedyne_fe2o3_steel::srv::{ |
| 21 | cfg::{ |
| 22 | TileConfig, |
| 23 | VhostConfig, |
| 24 | }, |
| 25 | tiles::{ |
| 26 | TileArchive, |
| 27 | TileInfo, |
| 28 | TileKind, |
| 29 | TileRequest, |
| 30 | TileService, |
| 31 | TileSource, |
| 32 | }, |
| 33 | }; |
| 34 | |
| 35 | use std::{ |
| 36 | collections::BTreeMap, |
| 37 | path::PathBuf, |
| 38 | pin::Pin, |
| 39 | }; |
| 40 | |
| 41 | use tokio::io::AsyncWriteExt; |
| 42 | |
| 43 | |
| 44 | const APP: &str = "https://oxegen.io"; |
| 45 | const PLAIN: &[u8] = b"a vector tile, pretending"; |
| 46 | |
| 47 | struct MemArchive { |
| 48 | tiles: BTreeMap<(u8, u32, u32), Vec<u8>>, |
| 49 | broken: bool, |
| 50 | } |
| 51 | |
| 52 | impl TileArchive for MemArchive { |
| 53 | fn info(&self) -> TileInfo { |
| 54 | TileInfo { |
| 55 | kind: TileKind::Mvt, |
| 56 | coding: ContentCoding::Gzip, |
| 57 | min_zoom: 0, |
| 58 | max_zoom: 15, |
| 59 | bounds_e7: [1_129_000_000, -437_000_000, 1_537_000_000, -106_000_000], |
| 60 | } |
| 61 | } |
| 62 | fn tile(&self, z: u8, x: u32, y: u32) -> Outcome<Option<Vec<u8>>> { |
| 63 | if self.broken { |
| 64 | return Err(err!("The test archive is broken on purpose."; Test, IO)); |
| 65 | } |
| 66 | Ok(self.tiles.get(&(z, x, y)).cloned()) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | fn config() -> TileConfig { |
| 71 | let mut builds = BTreeMap::new(); |
| 72 | builds.insert("20260922".to_string(), PathBuf::from("/srv/tiles/20260922.pmtiles")); |
| 73 | TileConfig { |
| 74 | prefix: "/t".to_string(), |
| 75 | current: "20260922".to_string(), |
| 76 | builds, |
| 77 | allow_origins: vec![APP.to_string()], |
| 78 | attribution: "© OpenStreetMap".to_string(), |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | fn service(broken: bool) -> Outcome<TileService<MemArchive>> { |
| 83 | let stored = res!(encoding::gzip(PLAIN)); |
| 84 | TileService::new(&config(), "tiles.oxegen.io", 63_072_000, |_, _| { |
| 85 | let mut tiles = BTreeMap::new(); |
| 86 | tiles.insert((14, 13_433, 9_798), stored.clone()); |
| 87 | tiles.insert((3, 7, 4), Vec::new()); |
| 88 | Ok(MemArchive { tiles, broken }) |
| 89 | }) |
| 90 | } |
| 91 | |
| 92 | async fn parse_request(raw: &str) -> Outcome<HttpMessage> { |
| 93 | let (mut near, mut far) = tokio::io::duplex(8192); |
| 94 | let bytes = raw.as_bytes().to_vec(); |
| 95 | tokio::spawn(async move { |
| 96 | let _ = far.write_all(&bytes).await; |
| 97 | let _ = far.flush().await; |
| 98 | }); |
| 99 | match res!(HttpMessage::read::<1024, 1024, _>( |
| 100 | Pin::new(&mut near), &Vec::new(), Some(true), None).await) |
| 101 | { |
| 102 | (Some(msg), _) => Ok(msg), |
| 103 | (None, _) => Err(err!("The test request did not parse."; Test, Invalid, Input)), |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The answer, as the lower-cased head and the body, the way a client would receive it. |
| 108 | struct Answer { |
| 109 | head: String, |
| 110 | body: Vec<u8>, |
| 111 | } |
| 112 | |
| 113 | impl Answer { |
| 114 | fn status(&self) -> &str { |
| 115 | self.head.get(9..12).unwrap_or("") |
| 116 | } |
| 117 | fn field(&self, name: &str) -> Option<String> { |
| 118 | let want = fmt!("{}: ", name); |
| 119 | self.head.split("\r\n") |
| 120 | .find(|l| l.starts_with(&want)) |
| 121 | .map(|l| l[want.len()..].to_string()) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | async fn ask(svc: &TileService<MemArchive>, raw: &str) -> Outcome<Option<Answer>> { |
| 126 | let msg = res!(parse_request(raw).await); |
| 127 | let req = res!(TileRequest::of(&msg).ok_or_else(|| err!( |
| 128 | "The test message is not a request."; Test, Invalid))); |
| 129 | let resp = match svc.respond(req).await { |
| 130 | Some(r) => r, |
| 131 | None => return Ok(None), |
| 132 | }; |
| 133 | let mut wire: Vec<u8> = Vec::new(); |
| 134 | res!(resp.write_all(&mut wire).await); |
| 135 | let split = res!(wire.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| err!( |
| 136 | "The answer has no end of head."; Test, Invalid))); |
| 137 | Ok(Some(Answer { |
| 138 | head: String::from_utf8_lossy(&wire[..split]).to_lowercase(), |
| 139 | body: wire[split + 4..].to_vec(), |
| 140 | })) |
| 141 | } |
| 142 | |
| 143 | async fn must(svc: &TileService<MemArchive>, raw: &str) -> Outcome<Answer> { |
| 144 | res!(ask(svc, raw).await).ok_or_else(|| err!( |
| 145 | "The route did not claim a path under its prefix."; Test, Missing)) |
| 146 | } |
| 147 | |
| 148 | fn get(path: &str, extra: &str) -> String { |
| 149 | fmt!("GET {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\n{}\r\n", path, extra) |
| 150 | } |
| 151 | |
| 152 | const TILE: &str = "/t/20260922/14/13433/9798.mvt"; |
| 153 | |
| 154 | #[tokio::test] |
| 155 | async fn stored_gzip_is_passed_through_and_cached_for_good() -> Outcome<()> { |
| 156 | let svc = res!(service(false)); |
| 157 | let a = res!(must(&svc, &get(TILE, |
| 158 | "Origin: https://oxegen.io\r\nAccept-Encoding: gzip, br\r\n")).await); |
| 159 | assert_eq!(a.status(), "200", "{}", a.head); |
| 160 | assert_eq!(a.field("content-encoding").as_deref(), Some("gzip")); |
| 161 | assert_eq!(a.field("content-type").as_deref(), Some("application/vnd.mapbox-vector-tile")); |
| 162 | assert_eq!(res!(encoding::gunzip(&a.body)), PLAIN.to_vec(), |
| 163 | "the stored bytes must reach the client as they are"); |
| 164 | assert_eq!(a.field("cache-control").as_deref(), |
| 165 | Some("public, max-age=31536000, immutable")); |
| 166 | assert_eq!(a.field("access-control-allow-origin").as_deref(), Some(APP)); |
| 167 | assert_eq!(a.field("vary").as_deref(), Some("origin, accept-encoding")); |
| 168 | assert_eq!(a.field("cross-origin-resource-policy").as_deref(), Some("same-site")); |
| 169 | assert_eq!(a.field("strict-transport-security").as_deref(), |
| 170 | Some("max-age=63072000; includesubdomains")); |
| 171 | assert!(a.field("access-control-allow-credentials").is_none()); |
| 172 | Ok(()) |
| 173 | } |
| 174 | |
| 175 | #[tokio::test] |
| 176 | async fn a_client_refusing_gzip_gets_the_plain_tile() -> Outcome<()> { |
| 177 | let svc = res!(service(false)); |
| 178 | let a = res!(must(&svc, &get(TILE, "Accept-Encoding: identity\r\n")).await); |
| 179 | assert_eq!(a.status(), "200", "{}", a.head); |
| 180 | assert!(a.field("content-encoding").is_none(), "{}", a.head); |
| 181 | assert_eq!(a.body, PLAIN.to_vec()); |
| 182 | // No Origin: served, but with nothing granting a cross-origin read. |
| 183 | assert!(a.field("access-control-allow-origin").is_none(), "{}", a.head); |
| 184 | Ok(()) |
| 185 | } |
| 186 | |
| 187 | #[tokio::test] |
| 188 | async fn an_empty_tile_is_204_and_cached_like_a_full_one() -> Outcome<()> { |
| 189 | let svc = res!(service(false)); |
| 190 | for path in ["/t/20260922/3/7/4.mvt", "/t/20260922/5/1/1.mvt"] { |
| 191 | let a = res!(must(&svc, &get(path, "Origin: https://oxegen.io\r\n")).await); |
| 192 | assert_eq!(a.status(), "204", "{}: {}", path, a.head); |
| 193 | assert!(a.body.is_empty()); |
| 194 | assert!(a.field("content-length").is_none(), "{}", a.head); |
| 195 | assert_eq!(a.field("cache-control").as_deref(), |
| 196 | Some("public, max-age=31536000, immutable")); |
| 197 | assert_eq!(a.field("access-control-allow-origin").as_deref(), Some(APP)); |
| 198 | } |
| 199 | Ok(()) |
| 200 | } |
| 201 | |
| 202 | #[tokio::test] |
| 203 | async fn paths_naming_no_tile_are_404_and_not_stored() -> Outcome<()> { |
| 204 | let svc = res!(service(false)); |
| 205 | for path in [ |
| 206 | "/t", |
| 207 | "/t/", |
| 208 | "/t/20260922", |
| 209 | "/t/20260101/14/13433/9798.mvt", // unknown build |
| 210 | "/t/20260922/16/0/0.mvt", // beyond the archive's zooms |
| 211 | "/t/20260922/3/8/0.mvt", // off the edge at z3 |
| 212 | "/t/20260922/3/0/8.mvt", |
| 213 | "/t/20260922/14/13433/9798.png", // not the archive's kind |
| 214 | "/t/20260922/14/013433/9798.mvt", // a second spelling of one tile |
| 215 | "/t/20260922/14/+13433/9798.mvt", |
| 216 | "/t/20260922/14/13433/9798", |
| 217 | "/t/20260922/14/13433/9798.mvt/x", |
| 218 | "/t/../t/20260922/14/13433/9798.mvt", |
| 219 | "/t/20260922/99999/0/0.mvt", |
| 220 | ] { |
| 221 | let a = res!(must(&svc, &get(path, "")).await); |
| 222 | assert_eq!(a.status(), "404", "{}: {}", path, a.head); |
| 223 | assert_eq!(a.field("cache-control").as_deref(), Some("no-store"), "{}", path); |
| 224 | } |
| 225 | Ok(()) |
| 226 | } |
| 227 | |
| 228 | #[tokio::test] |
| 229 | async fn paths_outside_the_prefix_are_not_claimed() -> Outcome<()> { |
| 230 | let svc = res!(service(false)); |
| 231 | for path in ["/", "/tx/20260922/14/13433/9798.mvt", "/tiles.json", "/index.html"] { |
| 232 | assert!(res!(ask(&svc, &get(path, "")).await).is_none(), "{} was claimed", path); |
| 233 | } |
| 234 | Ok(()) |
| 235 | } |
| 236 | |
| 237 | #[tokio::test] |
| 238 | async fn an_unlisted_origin_is_refused() -> Outcome<()> { |
| 239 | let svc = res!(service(false)); |
| 240 | for origin in ["https://evil.example", "http://oxegen.io", "https://oxegen.io.evil.example", |
| 241 | "null"] |
| 242 | { |
| 243 | let a = res!(must(&svc, &get(TILE, &fmt!("Origin: {}\r\n", origin))).await); |
| 244 | assert_eq!(a.status(), "403", "{}: {}", origin, a.head); |
| 245 | assert!(a.field("access-control-allow-origin").is_none(), "{}", a.head); |
| 246 | assert!(a.body.len() < 32, "a refused origin must not be handed the tile"); |
| 247 | } |
| 248 | Ok(()) |
| 249 | } |
| 250 | |
| 251 | #[tokio::test] |
| 252 | async fn cookies_and_credentials_change_nothing_and_none_are_set() -> Outcome<()> { |
| 253 | let svc = res!(service(false)); |
| 254 | let bare = res!(must(&svc, &get(TILE, |
| 255 | "Origin: https://oxegen.io\r\nAccept-Encoding: gzip\r\n")).await); |
| 256 | let loaded = res!(must(&svc, &get(TILE, |
| 257 | "Origin: https://oxegen.io\r\nAccept-Encoding: gzip\r\n\ |
| 258 | Cookie: sid=0123456789abcdef; member=alice\r\n\ |
| 259 | Authorization: Bearer abc.def\r\nX-Member-Id: 42\r\n\ |
| 260 | Referer: https://oxegen.io/map?at=-31.95,115.86\r\n")).await); |
| 261 | assert_eq!(bare.head, loaded.head, "the request's identity reached the answer"); |
| 262 | assert_eq!(bare.body, loaded.body); |
| 263 | for a in [&bare, &loaded] { |
| 264 | assert!(!a.head.contains("set-cookie"), "{}", a.head); |
| 265 | } |
| 266 | // And the index, the one answer that is not a tile. |
| 267 | let idx = res!(must(&svc, &get("/t/tiles.json", "Cookie: sid=1\r\n")).await); |
| 268 | assert!(!idx.head.contains("set-cookie"), "{}", idx.head); |
| 269 | Ok(()) |
| 270 | } |
| 271 | |
| 272 | #[tokio::test] |
| 273 | async fn preflight_head_and_other_methods() -> Outcome<()> { |
| 274 | let svc = res!(service(false)); |
| 275 | let a = res!(must(&svc, &fmt!( |
| 276 | "OPTIONS {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\nOrigin: https://oxegen.io\r\n\ |
| 277 | Access-Control-Request-Method: GET\r\n\r\n", TILE)).await); |
| 278 | assert_eq!(a.status(), "204", "{}", a.head); |
| 279 | assert_eq!(a.field("access-control-allow-origin").as_deref(), Some(APP)); |
| 280 | assert_eq!(a.field("access-control-allow-methods").as_deref(), Some("get, head, options")); |
| 281 | |
| 282 | let full = res!(must(&svc, &get(TILE, "Accept-Encoding: gzip\r\n")).await); |
| 283 | let head = res!(must(&svc, &fmt!( |
| 284 | "HEAD {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\nAccept-Encoding: gzip\r\n\r\n", |
| 285 | TILE)).await); |
| 286 | assert_eq!(head.status(), "200"); |
| 287 | assert!(head.body.is_empty(), "a HEAD answer carried a body"); |
| 288 | assert_eq!(head.head, full.head, "a HEAD must say what the GET would"); |
| 289 | |
| 290 | let a = res!(must(&svc, &fmt!( |
| 291 | "POST {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\nContent-Length: 0\r\n\r\n", |
| 292 | TILE)).await); |
| 293 | assert_eq!(a.status(), "405", "{}", a.head); |
| 294 | assert_eq!(a.field("allow").as_deref(), Some("get, head, options")); |
| 295 | Ok(()) |
| 296 | } |
| 297 | |
| 298 | #[tokio::test] |
| 299 | async fn the_index_names_the_current_build() -> Outcome<()> { |
| 300 | let svc = res!(service(false)); |
| 301 | let a = res!(must(&svc, &get("/t/tiles.json", "Origin: https://oxegen.io\r\n")).await); |
| 302 | assert_eq!(a.status(), "200", "{}", a.head); |
| 303 | assert_eq!(a.field("cache-control").as_deref(), Some("public, max-age=300")); |
| 304 | let dat = res!(Dat::decode_string(String::from_utf8_lossy(&a.body).to_string())); |
| 305 | let m = match dat { |
| 306 | Dat::Map(m) => m, |
| 307 | other => return Err(err!("The index is not a map: {:?}", other; Test, Invalid)), |
| 308 | }; |
| 309 | assert_eq!(m.get(&dat!("build")), Some(&dat!("20260922"))); |
| 310 | assert_eq!(m.get(&dat!("attribution")), Some(&dat!("© OpenStreetMap"))); |
| 311 | let body = String::from_utf8_lossy(&a.body).to_string(); |
| 312 | assert!(body.contains( |
| 313 | "\"https://tiles.oxegen.io/t/20260922/{z}/{x}/{y}.mvt\""), "{}", body); |
| 314 | assert!(body.contains("\"maxzoom\":15"), "{}", body); |
| 315 | Ok(()) |
| 316 | } |
| 317 | |
| 318 | #[tokio::test] |
| 319 | async fn a_failed_read_is_500_and_not_stored() -> Outcome<()> { |
| 320 | let svc = res!(service(true)); |
| 321 | let a = res!(must(&svc, &get(TILE, "")).await); |
| 322 | assert_eq!(a.status(), "500", "{}", a.head); |
| 323 | assert_eq!(a.field("cache-control").as_deref(), Some("no-store")); |
| 324 | Ok(()) |
| 325 | } |
| 326 | |
| 327 | // ── Config ────────────────────────────────────────────────────────────────── |
| 328 | |
| 329 | fn vhost(tiles: &str, access_log: &str) -> Outcome<VhostConfig> { |
| 330 | let text = fmt!("{{\"hostnames\": [\"tiles.oxegen.io\"], {} {}}}", tiles, access_log); |
| 331 | match res!(Dat::decode_string(text)) { |
| 332 | Dat::Map(m) => VhostConfig::from_datmap(&m), |
| 333 | _ => Err(err!("The test config is not a map."; Test, Invalid)), |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | const TILES: &str = "\"tiles\": {\"current\": \"20260922\", \ |
| 338 | \"builds\": {\"20260922\": \"/srv/tiles/20260922.pmtiles\"}, \ |
| 339 | \"allow_origins\": [\"https://oxegen.io\"]},"; |
| 340 | |
| 341 | #[test] |
| 342 | fn a_tile_vhost_must_turn_its_access_log_off() -> Outcome<()> { |
| 343 | let e = match vhost(TILES, "") { |
| 344 | Ok(_) => return Err(err!("A tile vhost with its access log on was accepted."; Test)), |
| 345 | Err(e) => e, |
| 346 | }; |
| 347 | assert!(fmt!("{}", e).contains("access_log"), "{}", e); |
| 348 | assert!(vhost(TILES, "\"access_log\": true").is_err()); |
| 349 | let v = res!(vhost(TILES, "\"access_log\": false")); |
| 350 | assert!(!v.access_log); |
| 351 | let t = res!(v.tiles.ok_or_else(|| err!("The tiles block was dropped."; Test, Missing))); |
| 352 | assert_eq!(t.prefix, "/t"); |
| 353 | assert_eq!(t.attribution, "© OpenStreetMap"); |
| 354 | // A vhost without tiles keeps its log unless told otherwise. |
| 355 | assert!(res!(vhost("", "")).access_log); |
| 356 | Ok(()) |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn allow_origins_reads_the_same_in_either_list_shape() -> Outcome<()> { |
| 361 | let vek = "\"tiles\": {\"current\": \"20260922\", \ |
| 362 | \"builds\": {\"20260922\": \"/srv/tiles/20260922.pmtiles\"}, \ |
| 363 | \"allow_origins\": (vek|[\"https://oxegen.io\", \"https://test.oxegen.io\"])},"; |
| 364 | let v = res!(vhost(vek, "\"access_log\": false")); |
| 365 | let t = res!(v.tiles.ok_or_else(|| err!("The tiles block was dropped."; Test, Missing))); |
| 366 | assert_eq!(t.allow_origins, vec!["https://oxegen.io".to_string(), "https://test.oxegen.io".to_string()]); |
| 367 | let v = res!(vhost(TILES, "\"access_log\": false")); |
| 368 | let t = res!(v.tiles.ok_or_else(|| err!("The tiles block was dropped."; Test, Missing))); |
| 369 | assert_eq!(t.allow_origins, vec!["https://oxegen.io".to_string()]); |
| 370 | Ok(()) |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn tile_config_refuses_what_it_cannot_honour() -> Outcome<()> { |
| 375 | for (why, tiles) in [ |
| 376 | ("relative path", "\"tiles\": {\"current\": \"a\", \"builds\": {\"a\": \"tiles/a.pmtiles\"}},"), |
| 377 | ("current absent", "\"tiles\": {\"current\": \"b\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}},"), |
| 378 | ("no builds", "\"tiles\": {\"current\": \"a\"},"), |
| 379 | ("wildcard origin", "\"tiles\": {\"current\": \"a\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}, \"allow_origins\": [\"*\"]},"), |
| 380 | ("origin with path", "\"tiles\": {\"current\": \"a\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}, \"allow_origins\": [\"https://oxegen.io/\"]},"), |
| 381 | ("slash in build", "\"tiles\": {\"current\": \"a/b\", \"builds\": {\"a/b\": \"/srv/a.pmtiles\"}},"), |
| 382 | ("dot build", "\"tiles\": {\"current\": \"..\", \"builds\": {\"..\": \"/srv/a.pmtiles\"}},"), |
| 383 | ("trailing slash prefix", "\"tiles\": {\"prefix\": \"/t/\", \"current\": \"a\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}},"), |
| 384 | ] { |
| 385 | assert!(vhost(tiles, "\"access_log\": false").is_err(), "accepted: {}", why); |
| 386 | } |
| 387 | Ok(()) |
| 388 | } |
| 389 | |
| 390 | fn sample() -> PathBuf { |
| 391 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fe2o3_geom/tests/data/protomaps/sample.pmtiles") |
| 392 | } |
| 393 | |
| 394 | #[test] |
| 395 | fn a_missing_archive_stops_start_up_and_a_real_one_opens() -> Outcome<()> { |
| 396 | // The configured path does not exist, so start-up is refused, naming the build. |
| 397 | let r = TileService::<TileSource>::new(&config(), "tiles.oxegen.io", 0, TileSource::open); |
| 398 | let e = match r { |
| 399 | Ok(_) => return Err(err!("An archive that is not there opened."; Test)), |
| 400 | Err(e) => e, |
| 401 | }; |
| 402 | assert!(fmt!("{:?}", e).contains("20260922"), "{:?}", e); |
| 403 | // A real archive, written by the reference Python writer, opens and says what it holds. |
| 404 | let src = res!(TileSource::open("sample", &sample())); |
| 405 | let info = src.info(); |
| 406 | assert_eq!(info.kind, TileKind::Mvt); |
| 407 | assert_eq!(info.coding, ContentCoding::Gzip); |
| 408 | assert_eq!((info.min_zoom, info.max_zoom), (12, 15)); |
| 409 | assert_eq!(info.bounds_e7, [1_156_000_000, -326_000_000, 1_163_000_000, -316_000_000]); |
| 410 | let stored = res!(src.tile(13, 6729, 4865)); |
| 411 | let plain = res!(encoding::gunzip(&res!(stored.ok_or_else(|| err!("No tile."; Test))))); |
| 412 | let want = res!(std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| 413 | .join("../fe2o3_geom/tests/data/protomaps/13_6729_4865.mvt"))); |
| 414 | assert_eq!(plain, want); |
| 415 | assert_eq!(res!(src.tile(13, 0, 0)), None); |
| 416 | Ok(()) |
| 417 | } |