aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/__init__.py15
-rw-r--r--tests/helpers.py39
-rw-r--r--tests/test_dedupe.py95
-rw-r--r--tests/test_deploy.py102
-rw-r--r--tests/test_export.py249
-rw-r--r--tests/test_refine.py805
-rw-r--r--tests/test_rivers.py266
-rw-r--r--tests/test_seafloor.py195
-rw-r--r--tests/test_serve.py1346
-rw-r--r--tests/test_servecache.py182
-rw-r--r--tests/test_tilecap.py81
-rw-r--r--tests/test_tiles.py1383
-rw-r--r--tests/test_viewer_fixtures.py23
13 files changed, 4781 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..c17e6de
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,15 @@
+import atexit
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+import worldgen_path # noqa: E402,F401 (mapgen on sys.path)
+
+if "WORLDMAP_TEST_TMP" not in os.environ:
+ _root = tempfile.mkdtemp(prefix="worldmap-viewer-tests-")
+ os.environ["WORLDMAP_TEST_TMP"] = os.environ["TMPDIR"] = tempfile.tempdir = _root
+ _pid = os.getpid()
+ atexit.register(lambda: os.getpid() == _pid and shutil.rmtree(_root, True))
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..1bb865b
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,39 @@
+import copy
+import functools
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+BASE_CFG = {
+ "planet": {"radius_km": 12742.0, "gravity_g": 1.05, "day_hours": 31.149, "year_days": 216,
+ "tilt_deg": 20.0, "sea_level_pressure_bar": 1.0, "scale_height_km": 8.0, "o2_fraction": 0.21},
+ "build": {"seed": 7, "res_dev": 2, "res_final": 2, "raster_width": 256, "preview_width": 128,
+ "land_fraction": 0.22},
+}
+
+
+@functools.lru_cache(maxsize=4)
+def small_grid(res: int = 2):
+ from mapgen.grid import build_grid
+ return build_grid(res, 12742.0)
+
+
+def make_ctx(res=2, data=None, cfg=None, tect=None, root=None):
+ from mapgen.pipeline import Ctx
+ c = copy.deepcopy(BASE_CFG)
+ for sec, vals in (cfg or {}).items():
+ c.setdefault(sec, {}).update(vals)
+ ctx = Ctx(root or ROOT, c, tect or {"plate": []}, res)
+ ctx.grid = small_grid(res)
+ ctx.data = dict(data or {})
+ return ctx
+
+
+def big_river(a) -> int:
+ """The cell of the biggest river that flows on over land (not into a lake at its own level, which cuts no
+ valley, nor off a coast, like a basin lake's spillway): the fixtures' river."""
+ import numpy as np
+ river, lake = np.asarray(a["river"]).astype(bool), np.asarray(a["lake"]).astype(bool)
+ recv = np.asarray(a["recv"])
+ wet = lake | np.asarray(a["ocean"]).astype(bool)
+ return int(np.argmax(np.asarray(a["discharge_km3_yr"]) * (river & ~wet & ~wet[recv])))
diff --git a/tests/test_dedupe.py b/tests/test_dedupe.py
new file mode 100644
index 0000000..2521f95
--- /dev/null
+++ b/tests/test_dedupe.py
@@ -0,0 +1,95 @@
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+
+import dedupe
+import servecache as SC
+
+B = dedupe.BLOCK
+
+
+class SameRunsTest(unittest.TestCase):
+ def test_runs_of_identical_whole_blocks(self):
+ rng = np.random.default_rng(0)
+ x = rng.integers(0, 256, 10 * B + 100, dtype=np.uint8)
+ y = x.copy()
+ y[2 * B + 5] ^= 1 # block 2 differs
+ y[3 * B] ^= 1 # block 3 differs
+ y[7 * B + B - 1] ^= 1 # block 7 differs (its last byte)
+ with tempfile.TemporaryDirectory() as t:
+ a, b = Path(t) / "a", Path(t) / "b"
+ a.write_bytes(x.tobytes())
+ b.write_bytes(y.tobytes())
+ fa, fb = os.open(a, os.O_RDONLY), os.open(b, os.O_RDONLY)
+ try:
+ runs = dedupe.same_runs(fa, fb, len(x))
+ finally:
+ os.close(fa)
+ os.close(fb)
+ self.assertEqual(runs, [(0, 2 * B), (4 * B, 3 * B), (8 * B, 2 * B)]) # the 100-byte tail is never shared
+
+ def test_dedupe_asks_for_each_identical_run_in_bounded_calls(self):
+ x = np.zeros(40 * B, np.uint8)
+ y = x.copy()
+ y[5 * B] = 1
+ calls = []
+ with tempfile.TemporaryDirectory() as t, \
+ mock.patch.object(dedupe, "_ioctl", lambda a, b, o, n: calls.append((o, n)) or n), \
+ mock.patch.object(dedupe, "MAX_CALL", 8 * B):
+ (Path(t) / "a").write_bytes(x.tobytes())
+ (Path(t) / "b").write_bytes(y.tobytes())
+ shared = dedupe.dedupe_file(Path(t) / "a", Path(t) / "b")
+ self.assertEqual(calls, [(0, 5 * B), (6 * B, 8 * B), (14 * B, 8 * B), (22 * B, 8 * B), (30 * B, 8 * B),
+ (38 * B, 2 * B)])
+ self.assertEqual(shared, 39 * B)
+
+ def test_unsupported_file_system_changes_nothing(self):
+ with tempfile.TemporaryDirectory() as t:
+ data = os.urandom(3 * B)
+ for n in "ab":
+ (Path(t) / n).write_bytes(data)
+ with mock.patch.object(dedupe, "_ioctl", side_effect=OSError(95, "Operation not supported")):
+ self.assertEqual(dedupe.dedupe_file(Path(t) / "a", Path(t) / "b"), 0)
+ self.assertEqual((Path(t) / "b").read_bytes(), data)
+
+ def test_different_sizes_are_skipped(self):
+ with tempfile.TemporaryDirectory() as t:
+ (Path(t) / "a").write_bytes(bytes(2 * B))
+ (Path(t) / "b").write_bytes(bytes(3 * B))
+ with mock.patch.object(dedupe, "_ioctl", side_effect=AssertionError):
+ self.assertEqual(dedupe.dedupe_file(Path(t) / "a", Path(t) / "b"), 0)
+
+
+class PeersTest(unittest.TestCase):
+ def test_a_peer_vanishing_midway_is_skipped(self):
+ with tempfile.TemporaryDirectory() as t:
+ new = Path(t) / "new"
+ new.mkdir()
+ (new / "x.npy").write_bytes(bytes(B))
+ with mock.patch.object(dedupe, "dedupe_file", side_effect=FileNotFoundError):
+ self.assertEqual(dedupe.dedupe_dirs(new, [Path(t) / "gone"]), 0)
+ with mock.patch.object(Path, "is_file", return_value=True), \
+ mock.patch.object(dedupe, "dedupe_file", side_effect=FileNotFoundError):
+ self.assertEqual(dedupe.dedupe_dirs(new, [Path(t) / "gone"]), 0)
+
+
+ def test_a_new_era_cache_is_shared_with_the_other_eras_complete_caches(self):
+ with tempfile.TemporaryDirectory() as t:
+ regions = Path(t) / "regions"
+ arrays = {"x": np.arange(10.0)}
+ old = SC.cache_dir(regions / "era-a", "k1")
+ SC.write(old, arrays, {})
+ (regions / "era-c" / f"{SC.PREFIX}half").mkdir(parents=True) # incomplete: not a peer
+ new = SC.cache_dir(regions / "era-b", "k2")
+ with mock.patch.object(SC.dedupe, "dedupe_dirs") as dd:
+ SC.write(new, arrays, {})
+ dd.assert_called_once_with(new, [old])
+ self.assertTrue(np.array_equal(SC.read(new)[0]["x"], np.arange(10.0)))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_deploy.py b/tests/test_deploy.py
new file mode 100644
index 0000000..679e621
--- /dev/null
+++ b/tests/test_deploy.py
@@ -0,0 +1,102 @@
+import contextlib
+import io
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+from mapgen import config as C
+from mapgen.testing import built_world
+
+import deploy
+import refine
+import servecache
+import tiles
+
+
+class BundleTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.tmp = Path(tempfile.mkdtemp())
+ cls.root = cls.tmp / "world"
+ shutil.copytree(built_world(), cls.root) # copy2: keeps mtimes (ns)
+ out = cls.root / "out" / "r2"
+ seed = int(C.load(cls.root)[0]["build"]["seed"])
+ cls.fp = tiles.fingerprint(out, seed)
+ tag = f"v{tiles.VERSION}.{tiles.DRAW}-{cls.fp}"
+ for rel, text in {
+ f"out/r2/regions/{cls.fp}-m{refine.MODEL}/a1/cells.npz": "current area",
+ "out/r2/regions/0000000000-m7/a1/cells.npz": "an earlier build's area",
+ "out/r2/regions/areas/k1/cells.npz": "shared area results",
+ f"out/r2/tiles/{tag}/relief/5/0/0.jpg": "current tile",
+ f"out/r2/tiles/{tag}+abc/relief/5/0/0.jpg": "current tile, refined set",
+ "out/r2/tiles/v1.1-0000000000/relief/5/0/0.jpg": "old tile",
+ f"out/r2/tiles/{tag}/relief/5/0/1.jpg.part": "half written",
+ "places/pins.json": "{\"pins\": []}",
+ "places/regions.json": "{\"regions\": []}",
+ "places/views.md": "notes",
+ "exports/x.zip": "export",
+ "lore/true-gods.md": "secret",
+ "sketch/a.png": "input",
+ }.items():
+ f = cls.root / rel
+ f.parent.mkdir(parents=True, exist_ok=True)
+ f.write_text(text)
+ cls.dest = cls.tmp / "dest"
+ with contextlib.redirect_stdout(io.StringIO()):
+ cls.rc = deploy.bundle(cls.root, str(cls.dest), 2)
+
+ @classmethod
+ def tearDownClass(cls):
+ shutil.rmtree(cls.tmp)
+
+ def test_copies_what_the_public_server_reads(self):
+ self.assertEqual(self.rc, 0)
+ tag = f"v{tiles.VERSION}.{tiles.DRAW}-{self.fp}"
+ for rel in ("config/world.toml", "places/regions.json", "out/r2/cells.npz", "out/r2/fields.json",
+ f"out/r2/regions/{self.fp}-m{refine.MODEL}/a1/cells.npz", "out/r2/regions/areas/k1/cells.npz",
+ f"out/r2/tiles/{tag}/relief/5/0/0.jpg", f"out/r2/tiles/{tag}+abc/relief/5/0/0.jpg"):
+ self.assertTrue((self.dest / rel).is_file(), rel)
+
+ def test_leaves_private_stale_and_input_files_behind(self):
+ for rel in ("places/pins.json", "places/views.md", "exports", "lore", "sketch",
+ "out/r2/regions/0000000000-m7", "out/r2/tiles/v1.1-0000000000"):
+ self.assertFalse((self.dest / rel).exists(), rel)
+ self.assertFalse(list(self.dest.rglob("*.part")))
+
+ def test_the_copy_keeps_the_build_fingerprint(self):
+ seed = int(C.load(self.dest)[0]["build"]["seed"])
+ self.assertEqual(tiles.fingerprint(self.dest / "out" / "r2", seed), self.fp)
+
+ def test_a_second_bundle_keeps_the_servers_own_tiles(self):
+ mine = self.dest / "out/r2/tiles/v9.9-ffffffffff/relief/9/1/1.jpg"
+ mine.parent.mkdir(parents=True, exist_ok=True)
+ mine.write_text("rendered on the server")
+ with contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(deploy.bundle(self.root, str(self.dest), 2), 0)
+ self.assertTrue(mine.exists())
+
+
+class DedupeWorldTest(unittest.TestCase):
+ def test_pairs_each_era_cache_with_earlier_peers(self):
+ tmp = Path(tempfile.mkdtemp())
+ try:
+ out = tmp / "out" / "r2"
+ dirs = [out / "regions" / k / f"{servecache.PREFIX}abc-v1" for k in ("aaa-m7", "bbb-m7", "ccc-m7")]
+ for d in dirs:
+ d.mkdir(parents=True)
+ (d / servecache.DONE).write_text("ok")
+ calls = []
+ orig = deploy.dedupe.dedupe_dirs
+ deploy.dedupe.dedupe_dirs = lambda new, peers: calls.append((new, sorted(peers))) or 7
+ try:
+ self.assertEqual(deploy.dedupe_world(tmp, 2), 21)
+ finally:
+ deploy.dedupe.dedupe_dirs = orig
+ self.assertEqual(calls, [(dirs[0], []), (dirs[1], [dirs[0]]), (dirs[2], [dirs[0], dirs[1]])])
+ finally:
+ shutil.rmtree(tmp)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_export.py b/tests/test_export.py
new file mode 100644
index 0000000..f5e3cb6
--- /dev/null
+++ b/tests/test_export.py
@@ -0,0 +1,249 @@
+import json
+import math
+import tempfile
+import unittest
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+import export as EX
+import serve
+import tiles as T
+from mapgen import config as C
+from tests.test_serve import built_world
+
+
+def _spot():
+ """A river mouth on the test world: land, sea and a river in one small square."""
+ a = np.load(built_world() / "out" / "r2" / "cells.npz")
+ mouths = np.where(a["river"].astype(bool) & a["ocean"][a["recv"]].astype(bool))[0]
+ i = int(mouths[np.argmax(a["discharge_km3_yr"][mouths])])
+ t = np.linspace(0, 1, 101)[:, None] # from the mouth toward its sea cell …
+ p = a["g_xyz"][i] * (1 - t) + a["g_xyz"][a["recv"][i]] * t
+ p /= np.linalg.norm(p, axis=1, keepdims=True)
+ lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
+ src = T.TileSource(serve.World(built_world(), 2), seed=int(C.load(built_world())[0]["build"]["seed"]),
+ cache_dir=Path(tempfile.mkdtemp()), regions_dir=Path(tempfile.mkdtemp()))
+ wet = src.surface_at(lat, lon, 1.0)[1]
+ k = int(np.argmax(wet)) if wet.any() else 50 # … to where the map shows the sea: the coast
+ return float(lat[k]), float(lon[k])
+
+
+class ExportTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.root = built_world()
+ cls.lat, cls.lon = _spot()
+ cls.pins = Path(tempfile.mkdtemp()) / "pins.json"
+ cls.pins.write_text(json.dumps({"pins": [{"id": "pin-1", "name": "Here", "lat": cls.lat, "lon": cls.lon,
+ "lore": "somewhere", "note": "", "epoch": None},
+ {"id": "pin-2", "name": "Far", "lat": -cls.lat, "lon": cls.lon + 90}]}))
+ cls.out = Path(tempfile.mkdtemp())
+ cls.d = EX.export(cls.root, 2, cls.lat, cls.lon, size_km=200, res_m=2000, out_dir=cls.out, name="t",
+ pins_path=cls.pins, regions_dir=Path(tempfile.mkdtemp()))
+ cls.meta = json.loads((cls.d / "meta.json").read_text())
+ cls.n = cls.meta["samples"]
+ cls.h = np.fromfile(cls.d / "height.f32", dtype="<f4").reshape(cls.n, cls.n)
+
+ def test_files_and_grid(self):
+ self.assertEqual(self.d, self.out / "t")
+ for f in ("height.f32", "height.png", "height.r16", "water.png", "biome.png", "ground.png", "landform.png",
+ "preview.png", "rivers.json", "pins.json", "legend.json", "meta.json", "README.txt"):
+ self.assertTrue((self.d / f).exists(), f)
+ self.assertEqual(self.n, 129, "2^n + 1 samples, the smallest covering 200 km at 2 km")
+ self.assertEqual(self.meta["res_m"], 2000)
+ self.assertEqual(self.meta["size_m"], 128 * 2000)
+ for f in ("water.png", "biome.png", "preview.png"):
+ self.assertEqual(Image.open(self.d / f).size, (self.n, self.n), f)
+ self.assertEqual(self.meta["sea_level_m"], 0)
+ self.assertIn("world_build", self.meta["sources"])
+
+ def test_samples_are_res_apart_on_the_ground(self):
+ lat, lon = EX.grid_latlon(self.lat, self.lon, self.n, 2000.0, 12742.0)
+ def gc(i1, j1, i2, j2):
+ p1, p2 = np.radians([lat[i1, j1], lon[i1, j1]]), np.radians([lat[i2, j2], lon[i2, j2]])
+ return 12742e3 * 2 * math.asin(math.sqrt(math.sin((p2[0] - p1[0]) / 2) ** 2 + math.cos(p1[0]) * math.cos(p2[0])
+ * math.sin((p2[1] - p1[1]) / 2) ** 2))
+ c = self.n // 2
+ for a, b in (((c, c), (c, c + 1)), ((0, 0), (0, 1)), ((0, 0), (1, 0)), ((self.n - 1, 5), (self.n - 2, 5))):
+ self.assertAlmostEqual(gc(*a, *b), 2000.0, delta=4.0)
+ self.assertAlmostEqual(lat[c, c], self.lat, places=9)
+ self.assertGreater(lat[0, c], lat[-1, c], "row 0 is the north edge")
+
+ def test_heights_are_the_viewers_terrain(self):
+ src = T.TileSource(serve.World(self.root, 2), seed=int(C.load(self.root)[0]["build"]["seed"]),
+ cache_dir=Path(tempfile.mkdtemp()), regions_dir=Path(tempfile.mkdtemp()))
+ lat, lon = EX.grid_latlon(self.lat, self.lon, self.n, 2000.0, 12742.0)
+ k = np.s_[::8, ::8]
+ ref = src.z_at(lat[k].ravel(), lon[k].ravel(), self.meta["tile_px_m"] / 1000.0).reshape(lat[k].shape)
+ self.assertGreater(np.corrcoef(ref.ravel(), self.h[k].ravel())[0, 1], 0.99)
+ self.assertLess(np.median(np.abs(ref - self.h[k])), 0.1 * ref.std() + 1.0)
+
+ def test_16_bit_heights_decode_to_the_float_heights(self):
+ lo, hi = self.meta["height"]["min"], self.meta["height"]["max"]
+ v = np.asarray(Image.open(self.d / "height.png"), dtype=np.float64)
+ raw = np.fromfile(self.d / "height.r16", dtype="<u2").reshape(self.n, self.n)
+ np.testing.assert_array_equal(v, raw)
+ np.testing.assert_allclose(lo + v / 65535 * (hi - lo), self.h, atol=(hi - lo) / 65535 + 1e-3)
+
+ def test_water_classes(self):
+ w = np.asarray(Image.open(self.d / "water.png"))
+ self.assertTrue(set(np.unique(w)) <= {0, 1, 2, 3})
+ self.assertTrue((w == 1).any() and (w == 0).any(), "the square holds sea and land")
+ self.assertLess(np.median(self.h[w == 1]), 0.0, "the sea lies below sea level")
+ self.assertEqual(json.loads((self.d / "legend.json").read_text())["water"], ["land", "sea", "lake", "river"])
+
+ def test_rivers_and_pins_in_local_metres(self):
+ rivers = json.loads((self.d / "rivers.json").read_text())
+ self.assertTrue(rivers, "the river mouth's river")
+ half = self.meta["size_m"] / 2
+ for r in rivers:
+ self.assertGreater(r["width_m"], 0)
+ p = np.asarray(r["points"])
+ self.assertTrue(np.all(np.abs(p) <= half + 1e-6))
+ pins = json.loads((self.d / "pins.json").read_text())
+ self.assertEqual([p["name"] for p in pins], ["Here"])
+ self.assertLess(abs(pins[0]["x"]) + abs(pins[0]["y"]), 1.0)
+ self.assertEqual(pins[0]["lore"], "somewhere")
+
+ def test_preview_shades_the_relief_so_gentle_land_still_shows(self):
+ pv = self.meta["preview"]
+ self.assertGreaterEqual(pv["exaggeration"], 1.0)
+ rgb = np.asarray(Image.open(self.d / "preview.png"), dtype=np.float64)
+ w = np.asarray(Image.open(self.d / "water.png"))
+ lum = rgb.mean(axis=2)[w == 0]
+ self.assertGreater(lum.std(), 2.0, "not a flat colour field")
+
+ def test_deterministic_in_parallel_too(self):
+ d2 = EX.export(self.root, 2, self.lat, self.lon, size_km=200, res_m=2000, out_dir=self.out, name="t2",
+ pins_path=self.pins, regions_dir=Path(tempfile.mkdtemp()), workers=2)
+ for f in ("height.f32", "water.png", "biome.png", "preview.png", "rivers.json", "pins.json"):
+ self.assertEqual((self.d / f).read_bytes(), (d2 / f).read_bytes(), f)
+
+ def test_exports_into_a_new_folder_leave_no_work_files(self):
+ out = Path(tempfile.mkdtemp()) / "new" / "exports"
+ d = EX.export(self.root, 2, self.lat, self.lon, size_km=50, res_m=2000, out_dir=out, name="n",
+ regions_dir=Path(tempfile.mkdtemp()))
+ self.assertEqual([p.name for p in out.iterdir()], ["n"])
+ self.assertTrue((d / "meta.json").exists())
+
+ def test_a_region_change_during_an_export_stops_it_cleanly(self):
+ import refine as RF
+ import shutil
+ from tests.test_refine import square
+ regions = Path(tempfile.mkdtemp())
+ reg = regions / "r.json"
+ reg.write_text(json.dumps({"regions": [square(self.lat, self.lon, 3)]}))
+ built = Path(tempfile.mkdtemp())
+ RF.build_areas(self.root, 2, reg, log=lambda s: None, regions_root=built)
+ src = T.TileSource(serve.World(self.root, 2), seed=int(C.load(self.root)[0]["build"]["seed"]),
+ cache_dir=Path(tempfile.mkdtemp()), regions_dir=regions / "live")
+ real, calls = src.export_tile, []
+
+ def tile(*a):
+ calls.append(a)
+ if len(calls) == 2: # a region build finishes mid-export
+ shutil.copytree(built, regions / "live")
+ src.reload_regions()
+ return real(*a)
+ src.export_tile = tile
+ out = Path(tempfile.mkdtemp())
+ with self.assertRaises(RuntimeError):
+ EX.export(self.root, 2, self.lat, self.lon, size_km=200, res_m=2000, out_dir=out, name="mixed", src=src)
+ self.assertEqual(list(out.iterdir()), [], "nothing half-mixed written")
+
+ def test_an_existing_export_is_not_overwritten(self):
+ with self.assertRaises(FileExistsError):
+ EX.export(self.root, 2, self.lat, self.lon, size_km=200, res_m=2000, out_dir=self.out, name="t",
+ pins_path=self.pins, regions_dir=Path(tempfile.mkdtemp()))
+
+
+class CliTest(unittest.TestCase):
+ def test_export_command(self):
+ import importlib.util
+ spec = importlib.util.spec_from_file_location("mapview_cli", Path(EX.__file__).parent / "mapview.py")
+ cli = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(cli)
+ lat, lon = _spot()
+ out = Path(tempfile.mkdtemp())
+ code = cli.main(["export", "--res", "2", "--lat", str(lat), "--lon", str(lon), "--size-km", "100",
+ "--res-m", "2000", "--name", "cli", "--out", str(out), "--workers", "0"], root=built_world())
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads((out / "cli" / "meta.json").read_text())["samples"], 65)
+
+ def test_exact_grid_512_m_at_1_m(self):
+ import importlib.util
+ spec = importlib.util.spec_from_file_location("mapview_cli", Path(EX.__file__).parent / "mapview.py")
+ cli = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(cli)
+ lat, lon = _spot()
+ out = Path(tempfile.mkdtemp())
+ self.assertEqual(cli.main(["export", "--res", "2", "--lat", str(lat), "--lon", str(lon), "--size-km", "0.512", "--res-m", "1", "--pixels", "513",
+ "--name", "c", "--out", str(out), "--workers", "0"], root=built_world()), 0)
+ meta = json.loads((out / "c" / "meta.json").read_text())
+ self.assertEqual((meta["samples"], meta["res_m"], meta["size_m"]), (513, 1, 512))
+ h = np.fromfile(out / "c" / "height.f32", dtype="<f4").reshape(513, 513)
+ self.assertAlmostEqual(meta["center_height_m"], float(h[256, 256]), places=3)
+
+class ExportForkTest(unittest.TestCase):
+ def test_region_trees_are_built_before_export_workers_fork(self):
+ import refine
+ from unittest import mock
+ order = []
+
+ class FakePool: # stands in for the forked pool: records when it forks
+ def __init__(self, n):
+ order.append("fork")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def imap(self, f, jobs, chunksize=1):
+ return map(f, jobs)
+ warm = refine.RegionSet.warm
+ a = np.load(built_world() / "out" / "r2" / "cells.npz")
+ i = int(np.argmax(a["z_surface_m"]))
+ with mock.patch.object(refine.RegionSet, "warm", autospec=True,
+ side_effect=lambda rs: (order.append("warm"), warm(rs))[1]), \
+ mock.patch.object(EX.multiprocessing, "get_context", return_value=mock.Mock(Pool=FakePool)):
+ EX.export(built_world(), 2, float(a["g_lat"][i]), float(a["g_lon"][i]), size_km=50, res_m=2000,
+ out_dir=Path(tempfile.mkdtemp()), name="w", workers=2)
+ self.assertIn("warm", order)
+ self.assertLess(order.index("warm"), order.index("fork"))
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class EraExportTest(unittest.TestCase):
+ def test_export_of_an_era_with_sea_and_zone_metadata(self):
+ import json
+ import export
+ from tests.test_refine import era_world
+ root, _, cut, _ = era_world()
+ out = Path(tempfile.mkdtemp())
+ a = export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="after-x")
+ b = export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="before-x",
+ era="before")
+ ma, mb = (json.loads((d / "meta.json").read_text()) for d in (a, b))
+ self.assertEqual((ma["era"]["name"], mb["era"]["name"]), ("after", "before"))
+ self.assertLess(ma["center_height_m"], mb["center_height_m"], "the cut lowered the ground")
+ for m in (ma, mb):
+ self.assertEqual(set(m["water"]), {"sea_fraction", "max_depth_m", "center_depth_m"})
+ self.assertEqual(set(m["zones"]), {"gravity_g", "o2_fraction", "po2_bar", "pressure_bar", "fire_reactivity"})
+ self.assertIsInstance(m["vents"], list)
+ self.assertEqual(m["format"], "worldmap-terrain-export")
+ self.assertAlmostEqual(ma["zones"]["fire_reactivity"], 0.4, places=3)
+ with self.assertRaises(SystemExit):
+ export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="n", era="nope")
+ from unittest import mock
+ import serve
+ real, made = serve.World, []
+ with mock.patch.object(serve, "World", side_effect=lambda *a, **k: made.append(k.get("era")) or real(*a, **k)):
+ export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="one", era="before")
+ self.assertEqual([e["name"] for e in made], ["before"], "only the chosen era's world is loaded")
diff --git a/tests/test_refine.py b/tests/test_refine.py
new file mode 100644
index 0000000..3284dc5
--- /dev/null
+++ b/tests/test_refine.py
@@ -0,0 +1,805 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+import h3.api.basic_int as h3
+import numpy as np
+
+import refine as RF
+from mapgen import config as C
+
+
+def square(lat, lon, d, rid="r1", name="test"):
+ return {"id": rid, "name": name, "outline": [[lat - d, lon - d], [lat - d, lon + d], [lat + d, lon + d], [lat + d, lon - d]]}
+
+
+class AreasTest(unittest.TestCase):
+ def test_regions_file(self):
+ self.assertEqual(RF.load_regions(Path(tempfile.mkdtemp()) / "none.json"), [])
+ p = Path(tempfile.mkdtemp()) / "regions.json"
+ p.write_text(json.dumps({"regions": [square(10, 20, 1)]}))
+ self.assertEqual(RF.load_regions(p)[0]["name"], "test")
+
+ def test_cells_inside_the_outline(self):
+ (cells,) = RF.area_cells([square(10, 20, 1)], 4)
+ lat = np.array([h3.cell_to_latlng(int(c))[0] for c in cells])
+ lon = np.array([h3.cell_to_latlng(int(c))[1] for c in cells])
+ self.assertTrue(len(cells) > 10)
+ self.assertTrue(np.all(np.abs(lat - 10) <= 1.1) and np.all(np.abs(lon - 20) <= 1.1))
+ self.assertTrue(np.all(np.diff(cells.astype(np.int64)) > 0), "sorted, unique")
+
+ def test_touching_outlines_merge_separate_ones_dont(self):
+ a, b = square(10, 20, 1, "a"), square(10, 22, 1, "b") # share the lon 21 edge
+ c = square(-30, 100, 1, "c")
+ areas = RF.area_cells([a, b, c], 4)
+ self.assertEqual(len(areas), 2)
+ self.assertEqual(sorted(len(x) for x in areas)[1], len(RF.area_cells([a], 4)[0]) + len(RF.area_cells([b], 4)[0]) - len(np.intersect1d(RF.area_cells([a], 4)[0], RF.area_cells([b], 4)[0])))
+
+ def test_hash_follows_the_cells_only(self):
+ (x,) = RF.area_cells([square(10, 20, 1, "a", "one")], 4)
+ (y,) = RF.area_cells([square(10, 20, 1, "b", "renamed")], 4)
+ (w,) = RF.area_cells([square(10, 20, 1.5)], 4)
+ self.assertEqual(RF.area_hash(x), RF.area_hash(y))
+ self.assertNotEqual(RF.area_hash(x), RF.area_hash(w))
+
+ def test_sea_only_where_it_reaches_the_open_ocean(self):
+ """World ocean cut off by a refined land strip is no sea (a lagoon: the lakes take it); as the world's own rule,
+ a separate basin counts as sea only when it is that big."""
+ (cells,) = RF.area_cells([square(10, 20, 3)], 4)
+ g, halo = RF.subgrid(cells, 12742.0)
+ d = np.hypot(g.lat - 10, g.lon - 20)
+ z = np.where((d > 0.9) & (d < 1.6), 5.0, -10.0) # a ring of land around a wet middle
+ ocean_world = np.ones(g.n, bool)
+ inner, outer = d <= 0.9, d >= 1.6
+ self.assertTrue(inner.sum() > 3 and (outer & halo).any())
+ sea = RF._sea(g, z, ocean_world, halo)
+ self.assertTrue(sea[outer].all(), "open water out to the area's edge: sea")
+ self.assertFalse(sea[inner].any(), "behind the strip: not sea")
+ big = RF._sea(g, z, ocean_world, halo, min_km2=float(g.area_km2[inner].sum()) * 0.9)
+ self.assertTrue(big[inner].all(), "a separate basin as big as a world sea stays sea")
+ self.assertFalse(RF._sea(g, z, np.zeros(g.n, bool), halo).any(), "never where the world has no ocean")
+
+ def test_world_lake_keeps_its_world_outflow(self):
+ """A world lake that drains to the world sea keeps that way out at its world level: the fine relief must not
+ dam it higher (r5 2026-10-04: lakes at 5 m stood at 29 m and drowned their shores). Only its world drainage
+ path is cut; a lake that already drains low enough is left alone."""
+ (cells,) = RF.area_cells([square(10, 20, 3)], 4)
+ g, halo = RF.subgrid(cells, 12742.0)
+ parent = np.where(g.lon < 19, 0, np.where(g.lon < 21, 1, 2)) # world: lake → land → ocean (west to east)
+ parent = np.where((g.lat > 11.5) & (parent == 1), 3, parent) # and land off the drainage path
+ world = {"lake": np.array([1, 0, 0, 0], bool), "ocean": np.array([0, 0, 1, 0], bool),
+ "lake_id": np.array([7, -1, -1, -1]), "lake_level_m": np.array([5.0, np.nan, np.nan, np.nan]),
+ "recv": np.array([1, 2, 2, 3])}
+ z = np.select([parent == 0, parent == 2], [-20.0, -900.0], 30.0) # a 30 m sill between lake and a deep sea
+ sea = RF._sea(g, z, world["ocean"][parent], halo)
+ lake = (parent == 0) & ~halo
+
+ def level(zz):
+ return float(RF.priority_flood(g, zz, sea)[lake].max())
+ self.assertGreater(level(z), 25.0, "the fine sill dams the lake")
+ cut = RF.world_outflows(g, z, halo, sea, parent, world)
+ self.assertLessEqual(level(cut), 5.0, "the lake drains at its world level")
+ self.assertTrue((cut <= z).all(), "only lowers")
+ self.assertTrue((cut[parent == 3] == z[parent == 3]).all(), "off the world path: untouched")
+ changed = cut < z
+ self.assertTrue(0 < changed.sum() < (parent == 1).sum() / 3, "a channel, not the whole cell")
+ self.assertTrue((cut[changed] >= 0.0).all(), "never cut below sea level (the sea's surface, not its bed)")
+ low = np.where(parent == 1, 2.0, z)
+ self.assertTrue((RF.world_outflows(g, low, halo, sea, parent, world) == low).all(), "drains already: untouched")
+
+ def test_subgrid_with_halo(self):
+ (cells,) = RF.area_cells([square(10, 20, 1)], 4)
+ g, halo = RF.subgrid(cells, 12742.0)
+ self.assertEqual(int((~halo).sum()), len(cells))
+ self.assertTrue(halo.sum() > 0)
+ pairs = set(zip(g.src.tolist(), g.dst.tolist()))
+ self.assertTrue(all((j, i) in pairs for i, j in pairs), "symmetric neighbours")
+ inner = np.where(~halo)[0]
+ self.assertTrue(np.all(g.counts[inner] >= 5), "every area cell keeps all its neighbours (pentagons: 5)")
+ self.assertAlmostEqual(float(g.area_km2[inner].mean()), h3.average_hexagon_area(4, unit="km^2") * 4, delta=0.3 * h3.average_hexagon_area(4, unit="km^2") * 4)
+
+
+from tests.test_serve import built_world
+from tests.helpers import big_river
+import serve
+import tiles as T
+
+
+class FieldsTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.root = built_world()
+ cls.world = RF.WorldCells(cls.root / "out" / "r2")
+ cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+ i = big_river(cls.world.a)
+ lat, lon = float(cls.world.a["g_lat"][i]), float(cls.world.a["g_lon"][i])
+ (cls.cells,) = RF.area_cells([square(lat, lon, 4)], 4)
+ cls.g, cls.halo = RF.subgrid(cls.cells, 12742.0)
+ cls.f = RF.initial_fields(cls.g, cls.halo, cls.world, cls.src)
+ cls.cfg = C.load(cls.root)[0]
+ cls.r = RF.refine_area(cls.g, cls.halo, cls.world, cls.src, cls.cfg)
+
+ def test_parents_are_the_world_cells(self):
+ p = self.f["parent"]
+ np.testing.assert_array_equal(self.world.a["g_ids"][p], [h3.cell_to_parent(int(c), 2) for c in self.g.ids])
+
+ def test_elevation_is_the_world_surface_plus_coarse_relief(self):
+ z = self.src.relief_at(self.g.lat, self.g.lon, RF.RELIEF_MIN_KM)
+ np.testing.assert_allclose(self.f["elevation_m"], z)
+
+ def test_temperature_follows_the_lapse_rate(self):
+ p = self.f["parent"]
+ dz = (self.f["elevation_m"] - self.world.a["z_surface_m"][p]) / 1000.0
+ np.testing.assert_allclose(self.f["T_mean"], self.world.a["T_mean"][p] - RF.LAPSE_C_PER_KM * dz, atol=1e-4)
+ np.testing.assert_array_equal(self.f["lithology"], self.world.a["lithology"][p])
+
+ def test_crossings_find_the_big_river(self):
+ inflow, exits, lev = RF.crossings(self.g, self.halo, self.world)
+ self.assertTrue(inflow.sum() > 0 or len(exits) > 0, "the widest world river crosses the square")
+ self.assertTrue(np.all(self.halo[exits]))
+ self.assertTrue(np.all(inflow[self.halo] == 0), "inflow enters inside the area")
+
+ def test_crossings_match_scanning_every_world_river(self):
+ """The near-area prefilter drops only segments that cannot cross (oracle: the unfiltered scan)."""
+ from unittest import mock
+ got = RF.crossings(self.g, self.halo, self.world)
+ with mock.patch.object(RF, "world_near", lambda w, cells, rings=3: np.arange(len(w.ids))):
+ want = RF.crossings(self.g, self.halo, self.world)
+ for a, b in zip(got, want):
+ np.testing.assert_array_equal(a, b)
+
+ def test_erosion_keeps_the_halo_and_the_sea_and_cuts_valleys(self):
+ f = self.f
+ sea = f["ocean_world"] & (f["elevation_m"] <= 0)
+ inflow, _, _ = RF.crossings(self.g, self.halo, self.world)
+ water = RF.runoff_km3(self.g, f) + inflow
+ z = RF.erode_area(self.g, f["elevation_m"], self.halo, sea, water, np.ones(self.g.n))
+ np.testing.assert_array_equal(z[self.halo], f["elevation_m"][self.halo])
+ np.testing.assert_array_equal(z[sea], f["elevation_m"][sea])
+ self.assertTrue((f["elevation_m"] - z).max() > 5.0, "valleys cut")
+ self.assertTrue(np.all(z <= f["elevation_m"] + 1e-6), "erosion only lowers")
+
+ def test_schema_and_halo(self):
+ for k in ("z_surface_m", "river", "recv", "discharge_km3_yr", "lake", "holdridge", "ground", "ice", "po2_bar"):
+ self.assertEqual(len(self.r[k]), self.g.n, k)
+ np.testing.assert_array_equal(self.r["halo"], self.halo)
+ self.assertFalse(self.r["river"][self.halo].any())
+
+ def test_water_balance(self):
+ s = RF.stats(self.r)
+ self.assertLess(s["water_balance_error"], 0.01)
+
+ def test_exit_where_the_world_exits(self):
+ _, exits, _ = RF.crossings(self.g, self.halo, self.world)
+ into = self.r["recv"][~self.halo]
+ big = np.where(self.r["river"] & ~self.halo & self.halo[self.r["recv"]])[0] # rivers leaving the area
+ if len(exits):
+ leave = set(self.r["recv"][big].tolist())
+ self.assertTrue(set(exits.tolist()) & leave, "the main river leaves through a world exit cell")
+
+ def test_no_river_dead_ends(self):
+ r, recv = self.r, self.r["recv"]
+ riv = np.where(r["river"])[0]
+ ok = r["river"][recv[riv]] | r["lake"][recv[riv]] | r["ocean"][recv[riv]] | r["halo"][recv[riv]] | r["endorheic"][recv[riv]]
+ self.assertTrue(np.all(ok))
+
+ def test_sea_stays_sea(self):
+ sea = self.f["ocean_world"] & (self.f["elevation_m"] <= 0)
+ self.assertTrue(np.all(self.r["ocean"][sea]))
+
+ def test_deterministic(self):
+ again = RF.refine_area(self.g, self.halo, self.world, self.src, self.cfg)
+ for k in ("z_surface_m", "discharge_km3_yr", "river"):
+ np.testing.assert_array_equal(again[k], self.r[k])
+
+ def test_streams_from_a_fifth_of_a_km3(self):
+ q = self.r["discharge_km3_yr"]
+ land = ~self.r["ocean"] & ~self.r["lake"] & ~self.halo
+ np.testing.assert_array_equal(self.r["river"][land], q[land] >= RF.RIVER_MIN_KM3_YR)
+
+ def test_lakes_only_in_real_hollows(self):
+ deep = self.r["depression_depth_m"] > RF.LAKE_MIN_DEPTH_M
+ self.assertFalse((self.r["lake"] & ~deep & ~self.r["endorheic"] & ~self.halo).any(), "no lakes in shallow dips")
+
+ def test_shoulder_heights_bridge_the_rivers(self):
+ env = RF.shoulders(self.r)
+ riv = self.r["river"] & ~self.halo
+ self.assertTrue(np.all(env[riv] >= self.r["z_surface_m"][riv] - 1e-6))
+ self.assertTrue(np.all(env[~riv] == self.r["z_surface_m"][~riv]))
+
+ def test_an_inflowing_river_carries_its_water_into_the_area(self):
+ inflow, _, _ = RF.crossings(self.g, self.halo, self.world)
+ if inflow.max() > 0:
+ q = self.r["discharge_km3_yr"][~self.halo]
+ self.assertGreaterEqual(q.max(), 0.9 * inflow.max(), "not bounced straight back out through the halo")
+ outlets = RF.outlets(self.g, self.halo, self.world, self.f["parent"])
+ self.assertTrue(outlets.any() and np.all(self.halo[outlets]))
+ self.assertLess(outlets.sum(), self.halo.sum(), "only some halo cells are ways out")
+
+ def test_no_block_steps_between_world_cells(self):
+ z, p, g = self.r["elevation_eroded_m"], self.f["parent"], self.g
+ inner = ~self.halo[g.src] & ~self.halo[g.dst] & ~self.r["ocean"][g.src] & ~self.r["ocean"][g.dst]
+ d = np.abs(z[g.src] - z[g.dst])[inner]
+ across = (p[g.src] != p[g.dst])[inner]
+ if across.sum() > 10 and (~across).sum() > 10:
+ self.assertLess(np.percentile(d[across], 95), 1.3 * np.percentile(d[~across], 95) + 5.0)
+
+ def test_world_cell_means_of_bedrock_are_kept_smoothly(self):
+ z, p = self.r["elevation_eroded_m"], self.f["parent"]
+ for w in np.unique(p[~self.halo]):
+ m = (p == w) & ~self.halo
+ floor = (self.r["canyon_m"][m] > 0).any() or (self.r["fan_m"][m] > 0).any() # the sea-floor pass: sea only
+ if m.sum() == 49 and not self.world.a["ocean"][w] and not floor:
+ self.assertAlmostEqual(float(z[m].mean()), float(self.world.a["elevation_eroded_m"][w]), delta=25.0)
+
+ def test_no_fake_lakes_against_walls(self):
+ g, r = self.g, self.r
+ wall = self.halo & ~r["outlet"]
+ touching = set(r["lake_id"][g.src[wall[g.dst] & r["lake"][g.src]]].tolist())
+ for lid in touching: # a wall lower than the lake would have let it spill out
+ m = r["lake"] & (r["lake_id"] == lid)
+ walls = g.dst[m[g.src] & wall[g.dst]]
+ level = float(r["z_filled_m"][m].max())
+ self.assertTrue(np.all(r["elevation_eroded_m"][walls] >= level - 1e-6), f"lake {lid} dammed by a wall")
+
+ def test_temperatures_follow_the_final_heights(self):
+ p = self.f["parent"]
+ dz = (self.r["elevation_eroded_m"] - self.world.a["elevation_eroded_m"][p]) / 1000.0
+ np.testing.assert_allclose(self.r["T_mean"], self.world.a["T_mean"][p] - RF.LAPSE_C_PER_KM * dz, atol=1e-3)
+
+ def test_border_report(self):
+ s = RF.stats(self.r)
+ self.assertIn("exit_level_error_m", s)
+ if s["exit_level_error_m"] is not None:
+ self.assertLess(abs(s["exit_level_error_m"]), 50.0, "rivers leave at the world river's level")
+
+ def test_zone_fields_come_from_the_world_cells(self):
+ from mapgen import fields as FL
+ p, a, r = self.f["parent"], self.world.a, self.r
+ for k in ("gravity_g", "o2_fraction", "fire_reactivity", "plant_height_x"):
+ np.testing.assert_array_equal(r[k], a[k][p], err_msg=k)
+ H = float(self.cfg["planet"]["scale_height_km"]) * 1000.0
+ sea = a["pressure_bar"][p] / FL.pressure(1.0, a["z_surface_m"][p], H)
+ np.testing.assert_allclose(r["pressure_bar"], sea * FL.pressure(1.0, r["z_surface_m"], H), rtol=1e-5)
+ np.testing.assert_allclose(r["po2_bar"], r["o2_fraction"] * r["pressure_bar"], rtol=1e-5)
+
+
+def clear_regions(root): # built_world() is shared across tests: leave no regions behind
+ import shutil
+ shutil.rmtree(root / "out" / "r2" / "regions", ignore_errors=True)
+ (root / "places" / "regions.json").unlink(missing_ok=True)
+
+
+class BuildTest(unittest.TestCase):
+ def tearDown(self):
+ clear_regions(built_world())
+
+ def test_build_caches_per_world_and_skips_when_done(self):
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ i = big_river(w.a)
+ reg = root / "places" / "regions.json"
+ reg.parent.mkdir(exist_ok=True)
+ reg.write_text(json.dumps({"regions": [square(float(w.a["g_lat"][i]), float(w.a["g_lon"][i]), 2.5)]})) # its own area (built_world is shared)
+ m1 = RF.build_areas(root, 2, reg, log=lambda s: None)
+ self.assertEqual(len(m1), 1)
+ self.assertTrue(m1[0]["built"])
+ d = Path(m1[0]["dir"])
+ self.assertTrue((d / "cells.npz").exists() and (d / "meta.json").exists())
+ m2 = RF.build_areas(root, 2, reg, log=lambda s: None)
+ self.assertFalse(m2[0]["built"], "cached: not rebuilt")
+ stale = d.parent.parent / "0000000000-m1"
+ stale.mkdir()
+ RF.build_areas(root, 2, reg, log=lambda s: None)
+ self.assertFalse(stale.exists(), "results of other world builds are removed")
+
+ def test_redrawn_and_deleted_regions_leave_no_old_area(self):
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ i = big_river(w.a)
+ la, lo = float(w.a["g_lat"][i]), float(w.a["g_lon"][i])
+ reg = root / "places" / "regions.json"
+ reg.parent.mkdir(exist_ok=True)
+ reg.write_text(json.dumps({"regions": [square(la, lo, 2)]}))
+ (m1,) = RF.build_areas(root, 2, reg, log=lambda s: None)
+ reg.write_text(json.dumps({"regions": [square(la, lo, 3)]})) # redrawn larger
+ (m2,) = RF.build_areas(root, 2, reg, log=lambda s: None)
+ self.assertFalse(Path(m1["dir"]).exists(), "the old area is gone")
+ (Path(m2["dir"]) / "meta.json").write_text("{broken") # a crash mid-save
+ (m3,) = RF.build_areas(root, 2, reg, log=lambda s: None)
+ self.assertTrue(m3["built"], "a broken result is rebuilt, not fatal")
+ reg.write_text(json.dumps({"regions": []}))
+ self.assertEqual(RF.build_areas(root, 2, reg, log=lambda s: None), [])
+ self.assertEqual(RF.built_areas(root / "out" / "r2" / "regions", Path(m2["dir"]).parent.name.rsplit("-m", 1)[0]), [])
+
+
+ def test_a_failed_build_keeps_the_previous_result(self):
+ from unittest import mock
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ i = big_river(w.a)
+ la, lo = float(w.a["g_lat"][i]), float(w.a["g_lon"][i])
+ tmp = Path(tempfile.mkdtemp())
+ reg = tmp / "regions.json"
+ reg.write_text(json.dumps({"regions": [square(la, lo, 2)]}))
+ (m1,) = RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ reg.write_text(json.dumps({"regions": [square(la, lo, 3)]})) # extended …
+ with mock.patch.object(RF, "refine_area", side_effect=RuntimeError("boom")), self.assertRaises(RuntimeError):
+ RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res") # … and the build fails
+ self.assertTrue((Path(m1["dir"]) / "cells.npz").exists(), "the old area stays until a build succeeds")
+
+
+class LandOutlineTest(unittest.TestCase):
+ def test_outline_of_a_landmass_covers_it_simply(self):
+ root = built_world()
+ a = RF.WorldCells(root / "out" / "r2").a
+ land = ~a["ocean"].astype(bool)
+ i = int(np.argmax(land * a["z_surface_m"])) # a point on the highest landmass
+ outline = RF.land_outline(a, float(a["g_lat"][i]), float(a["g_lon"][i]), step_deg=2.0)
+ self.assertGreaterEqual(len(outline), 4)
+ self.assertLessEqual(len(outline), 500)
+ import serve
+ self.assertFalse(serve.RegionStore._crosses([(lo, la) for la, lo in outline]), "a simple polygon")
+ cells = set(h3.polygon_to_cells(h3.LatLngPoly([tuple(p) for p in outline]), 2))
+ comp = RF.landmass(a, float(a["g_lat"][i]), float(a["g_lon"][i]))
+ inside = np.isin(a["g_ids"][comp], np.array(sorted(cells), dtype=np.uint64))
+ self.assertGreater(inside.mean(), 0.97, "the landmass lies inside its outline")
+
+
+class RecordTest(unittest.TestCase):
+ def test_the_record_keeps_what_was_built_not_later_edits(self):
+ from unittest import mock
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ i = big_river(w.a)
+ la, lo = float(w.a["g_lat"][i]), float(w.a["g_lon"][i])
+ tmp = Path(tempfile.mkdtemp())
+ reg = tmp / "regions.json"
+ a, b = square(la, lo, 2, "a"), square(-la, lo + 90, 2, "b")
+ reg.write_text(json.dumps({"regions": [a]}))
+ real = RF.refine_area
+
+ def edit_meanwhile(*args, **kw):
+ reg.write_text(json.dumps({"regions": [a, b]})) # region b drawn during the build
+ return real(*args, **kw)
+ with mock.patch.object(RF, "refine_area", side_effect=edit_meanwhile):
+ RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ key = next((tmp / "res").glob("*-m*")).name.rsplit("-m", 1)[0]
+ rec = RF.read_record(tmp / "res", key)["regions"]
+ self.assertEqual(set(rec), {"a"}, "b was not built: it must not look built")
+
+
+def _subgrid_reference(cells, radius_km): # the first implementation (sets and lists): the oracle
+ inner = set(int(c) for c in cells)
+ ring = {n for c in inner for n in h3.grid_ring(c, 1)} - inner
+ ids = np.array(sorted(inner | ring), dtype=np.uint64)
+ rings = [[n for n in h3.grid_ring(int(c), 1)] for c in ids]
+ pos = {int(c): k for k, c in enumerate(ids)}
+ nb = [[pos[n] for n in r if n in pos] for r in rings]
+ ptr = np.concatenate([[0], np.cumsum([len(x) for x in nb])]).astype(np.int64)
+ idx = np.array([k for x in nb for k in x], dtype=np.int64)
+ return ids, ptr, idx, ~np.isin(ids, cells)
+
+
+def _area_cells_reference(regions, fine_res): # the first implementation (flood fill over every cell)
+ cells = set()
+ for r in regions:
+ cells.update(h3.polygon_to_cells(h3.LatLngPoly([tuple(p) for p in r["outline"]]), fine_res))
+ left, out = set(cells), []
+ while left:
+ seed = left.pop()
+ comp, stack = [seed], [seed]
+ while stack:
+ for n in h3.grid_ring(stack.pop(), 1):
+ if n in left:
+ left.discard(n)
+ comp.append(n)
+ stack.append(n)
+ out.append(np.array(sorted(comp), dtype=np.uint64))
+ return sorted(out, key=lambda a: int(a[0]))
+
+
+class LeanBuildTest(unittest.TestCase):
+ def test_subgrid_is_the_same_grid(self):
+ (cells,) = RF.area_cells([square(10, 20, 4)], 4)
+ g, halo = RF.subgrid(cells, 12742.0)
+ ids, ptr, idx, h = _subgrid_reference(cells, 12742.0)
+ np.testing.assert_array_equal(g.ids, ids)
+ np.testing.assert_array_equal(g.nbr_ptr, ptr)
+ np.testing.assert_array_equal(g.nbr_idx, idx)
+ np.testing.assert_array_equal(halo, h)
+ np.testing.assert_allclose(g.lat, [h3.cell_to_latlng(int(c))[0] for c in ids])
+
+ def test_areas_are_the_same_areas(self):
+ cases = [[square(10, 20, 3, "a"), square(10, 25.9, 3, "b")], # touching: one area
+ [square(10, 20, 3, "a"), square(10, 40, 3, "b")], # apart: two
+ [square(10, 20, 6, "a"), square(10, 20, 1, "b")], # nested
+ [square(-30, 170, 3, "a"), square(-30, -176, 3, "b"), square(40, 0, 2, "c")]] # across ±180
+ for regions in cases:
+ got, want = RF.area_cells(regions, 4), _area_cells_reference(regions, 4)
+ self.assertEqual(len(got), len(want))
+ for a, b in zip(got, want):
+ np.testing.assert_array_equal(a, b)
+
+ def test_a_build_does_not_load_the_refined_regions(self):
+ from unittest import mock
+ root = built_world()
+ tmp = Path(tempfile.mkdtemp())
+ reg = tmp / "r.json"
+ reg.write_text(json.dumps({"regions": [square(10, 20, 3)]}))
+ RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ reg.write_text(json.dumps({"regions": [square(10, 20, 3), square(-20, 60, 3, "b")]}))
+ real = RF.RegionSet
+
+ def only_empty(root_, *a, **k):
+ rs = real(root_, *a, **k)
+ if not rs.empty:
+ raise AssertionError("a build loaded the refined regions (memory for nothing)")
+ return rs
+ with mock.patch.object(RF, "RegionSet", side_effect=only_empty):
+ RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+
+
+class FingerprintTest(unittest.TestCase):
+ def test_region_fingerprint_changes_when_a_result_is_rebuilt(self):
+ d = Path(tempfile.mkdtemp())
+ area = RF.results_dir(d, "abc") / "a1"
+ area.mkdir(parents=True)
+ np.savez(area / "cells.npz", g_ids=np.array([1], np.uint64))
+ (area / "meta.json").write_text("{}")
+ f1 = RF.regions_fingerprint(d, "abc")
+ import os, time
+ st = (area / "cells.npz").stat()
+ os.utime(area / "cells.npz", ns=(st.st_atime_ns, st.st_mtime_ns + 10**9)) # rebuilt (new model, same cells)
+ self.assertNotEqual(RF.regions_fingerprint(d, "abc"), f1)
+
+
+class HaloHeightsTest(unittest.TestCase):
+ def test_halo_keeps_real_heights_in_the_output(self):
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ src = T.TileSource(serve.World(root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+ i = big_river(w.a)
+ (cells,) = RF.area_cells([square(float(w.a["g_lat"][i]), float(w.a["g_lon"][i]), 4)], 4)
+ g, halo = RF.subgrid(cells, 12742.0)
+ r = RF.refine_area(g, halo, w, src, C.load(root)[0])
+ self.assertLess(float(np.max(r["z_surface_m"])), 20000.0, "no routing walls leak into heights")
+
+
+class CoastTest(unittest.TestCase):
+ def test_coastal_area_keeps_land_and_rivers_above_the_sea(self):
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ a = w.a
+ mouths = np.where(a["river"].astype(bool) & a["ocean"][a["recv"]].astype(bool))[0]
+ i = int(mouths[np.argmax(a["discharge_km3_yr"][mouths])])
+ (cells,) = RF.area_cells([square(float(a["g_lat"][i]), float(a["g_lon"][i]), 4)], 4)
+ g, halo = RF.subgrid(cells, 12742.0)
+ src = T.TileSource(serve.World(root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+ r = RF.refine_area(g, halo, w, src, C.load(root)[0])
+ inner = ~halo
+ self.assertTrue((r["ocean"] & inner).any(), "the test area reaches the sea")
+ self.assertFalse((r["river"] & inner & (r["elevation_eroded_m"] < 0)).any(), "no river below sea level")
+ lake_low = r["lake"] & inner & ~r["endorheic"] & (r["z_filled_m"] < 0)
+ self.assertFalse(lake_low.any(), "no open lake below sea level")
+ coast_land = inner & ~r["ocean"] & np.isin(np.arange(g.n), g.src[r["ocean"][g.dst]])
+ self.assertTrue(np.all(r["elevation_eroded_m"][coast_land] >= 0), "land by the sea is above it")
+
+
+ERA_CUT = """
+[[event]]
+name = "cut"
+kind = "disintegrate"
+center = [{lat}, {lon}]
+radius_km = 1000.0
+depth_m = 3000.0
+[[event]]
+name = "haze"
+kind = "zone"
+shape = "circle"
+center = [{lat}, {lon}]
+radius_km = 1500.0
+edge_km = 5.0
+fields = {{ gravity_g = 0.5, pressure_bar = 2.0, fire_reactivity = 0.4 }}
+[eras]
+order = ["before", "after"]
+default = "after"
+[eras.before]
+label = "Before"
+events = []
+[eras.after]
+label = "After"
+events = ["cut", "haze"]
+"""
+
+_ERA_WORLD = []
+
+
+def era_world():
+ """(root, far point, cut point, km between them): a small world with an era 'after' that cuts into the land
+ farthest from the biggest river and holds a zone there (built once per test run)."""
+ if not _ERA_WORLD:
+ import atexit
+ import shutil
+ from mapgen import eras as ER, pipeline as P
+ from mapgen.sphere import gc_dist_km
+ from mapgen.testing import small_world
+ root = Path(tempfile.mkdtemp(prefix="worldmap-viewer-era-"))
+ atexit.register(shutil.rmtree, root, True)
+ small_world(root)
+ ctx = P.build(root, 2, log=lambda m: None)
+ g, a = ctx.grid, ctx.data
+ i = big_river(a)
+ d = gc_dist_km(g.xyz, g.xyz[i], g.radius_km)
+ j = int(np.argmax(np.where(~np.asarray(a["ocean"]), d, -1.0)))
+ (root / "config" / "eras.toml").write_text(ERA_CUT.format(lat=round(float(g.lat[j]), 3),
+ lon=round(float(g.lon[j]), 3)))
+ ER.build_all(root, 2, log=lambda m: None, base=ctx)
+ _ERA_WORLD.append((root, (float(g.lat[i]), float(g.lon[i])), (float(g.lat[j]), float(g.lon[j])), float(d[j])))
+ return _ERA_WORLD[0]
+
+
+def write_regions(path: Path, *squares) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps({"regions": list(squares)}))
+ return path
+
+
+class EraAreasTest(unittest.TestCase):
+ def test_an_area_the_era_left_alone_is_built_once(self):
+ root, far, cut, dist = era_world()
+ self.assertGreater(dist, 5500.0, "the fixture needs the river far from the cut")
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(*far, 2, "far"), square(*cut, 2, "cut"))
+ metas = RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ self.assertEqual(sorted({m["era"] for m in metas}), ["after", "before"])
+ by = {(m["era"], m["hash"]): m for m in metas}
+ hf = RF.area_hash(RF.area_cells([square(*far, 2)], 4)[0])
+ hc = RF.area_hash(RF.area_cells([square(*cut, 2)], 4)[0])
+ self.assertEqual(by[("before", hf)]["key"], by[("after", hf)]["key"])
+ self.assertNotEqual(by[("before", hc)]["key"], by[("after", hc)]["key"])
+ self.assertEqual(sorted(p.name for p in RF.areas_dir(tmp / "res").iterdir() if p.is_dir()),
+ sorted({m["key"] for m in metas}))
+ self.assertEqual(len({m["key"] for m in metas}), 3)
+ sets = [p for p in (tmp / "res").iterdir() if p.name != RF.AREAS]
+ self.assertEqual(len(sets), 2)
+ for s in sets:
+ links = RF.built_areas(tmp / "res", s.name.rsplit("-m", 1)[0])
+ self.assertEqual(len(links), 2)
+ self.assertTrue(all(p.is_symlink() for p in links))
+ self.assertIn(json.loads((s / "era.json").read_text())["era"], ("before", "after"))
+ again = RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ self.assertFalse(any(m["built"] for m in again), "cached for both worlds")
+
+ def test_key_follows_the_values_the_area_reads(self):
+ import copy
+ root, far, _, _ = era_world()
+ cfg, _ = C.load(root)
+ (cells,) = RF.area_cells([square(*far, 2)], 4)
+ w = RF.WorldCells(root / "out" / "r2")
+ k0 = RF.area_key(w, cells, cfg, [])
+ self.assertEqual(RF.area_key(RF.WorldCells(root / "out" / "r2"), cells, cfg, []), k0, "deterministic")
+ idx = RF.world_near(w, cells)
+ par = np.unique([h3.cell_to_parent(int(c), 2) for c in cells])
+ self.assertTrue(np.isin(np.searchsorted(w.ids, par.astype(np.uint64)), idx).all(), "the cells under it")
+ near = RF.WorldCells(root / "out" / "r2")
+ near.a["T_mean"] = near.a["T_mean"].copy()
+ near.a["T_mean"][idx[0]] += 1.0
+ self.assertNotEqual(RF.area_key(near, cells, cfg, []), k0)
+ away = RF.WorldCells(root / "out" / "r2")
+ away.a["T_mean"] = away.a["T_mean"].copy()
+ away.a["T_mean"][np.setdiff1d(np.arange(len(away.ids)), idx)[0]] += 1.0
+ self.assertEqual(RF.area_key(away, cells, cfg, []), k0, "values far away don't matter")
+ cfg2 = copy.deepcopy(cfg)
+ cfg2["build"]["seed"] = int(cfg2["build"]["seed"]) + 1
+ self.assertNotEqual(RF.area_key(w, cells, cfg2, []), k0)
+
+ def test_an_unbuilt_era_waits_with_a_message(self):
+ root = built_world()
+ p = root / "config" / "eras.toml"
+ p.write_text(ERA_CUT.format(lat=0.0, lon=0.0))
+ try:
+ tmp = Path(tempfile.mkdtemp())
+ w = RF.WorldCells(root / "out" / "r2")
+ i = big_river(w.a)
+ reg = write_regions(tmp / "regions.json", square(float(w.a["g_lat"][i]), float(w.a["g_lon"][i]), 2))
+ logs = []
+ metas = RF.build_areas(root, 2, reg, log=logs.append, regions_root=tmp / "res")
+ self.assertEqual({m["era"] for m in metas}, {"before"})
+ self.assertTrue(any("era after is not built" in s for s in logs), logs)
+ finally:
+ p.unlink()
+
+ def test_refined_cells_keep_the_eras_air(self):
+ from mapgen import environment as EN, eras as ER, fields as FL
+ root, _, cut, _ = era_world()
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(*cut, 2, "cut"))
+ metas = RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ (m,) = [m for m in metas if m["era"] == "after"]
+ w = RF.WorldCells(ER.era_dir(root, 2, "after"))
+ with np.load(Path(m["dir"]) / "cells.npz") as z:
+ r = {k: z[k] for k in z.files}
+ p = r["parent"]
+ np.testing.assert_array_equal(r["gravity_g"], w.a["gravity_g"][p])
+ self.assertTrue(np.any(np.isclose(r["fire_reactivity"], 0.4)), "the zone reaches the refined cells")
+ cfg = C.load(root)[0]
+ H = float(cfg["planet"]["scale_height_km"]) * 1000.0
+ sea_p = r["pressure_bar"] / FL.pressure(1.0, r["z_surface_m"], H)
+ wet = np.sqrt(sea_p / float(cfg["planet"]["sea_level_pressure_bar"]))
+ zone, _ = EN.holdridge(r["biotemp"], r["P_ann"] * wet, r["T_min"])
+ inner = ~r["halo"]
+ np.testing.assert_array_equal(r["holdridge"][inner], zone[inner])
+
+
+class LayoutTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ w = RF.WorldCells(self.root / "out" / "r2")
+ i = big_river(w.a)
+ self.at = (float(w.a["g_lat"][i]), float(w.a["g_lon"][i]))
+ self.sq = square(*self.at, 2)
+ self.tmp = Path(tempfile.mkdtemp())
+ self.reg = write_regions(self.tmp / "regions.json", self.sq)
+ self.res = self.tmp / "res"
+
+ def fingerprint(self):
+ return T.TileSource(serve.World(self.root, 2), seed=int(C.load(self.root)[0]["build"]["seed"]),
+ regions_dir=self.res, with_regions=False).fingerprint
+
+ def test_old_layout_is_replaced(self):
+ h = RF.area_hash(RF.area_cells([self.sq], 4)[0])
+ old = RF.results_dir(self.res, self.fingerprint()) / h
+ old.mkdir(parents=True)
+ (old / "cells.npz").write_bytes(b"old")
+ (self.res / "0123456789-m5" / "x").mkdir(parents=True)
+ (m,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ link = Path(m["dir"])
+ self.assertTrue(link.is_symlink())
+ with np.load(link / "cells.npz") as z:
+ self.assertIn("g_ids", z.files)
+ self.assertFalse((self.res / "0123456789-m5").exists())
+
+ def test_a_dangling_link_is_rebuilt(self):
+ import shutil
+ (m,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ shutil.rmtree(Path(m["dir"]).resolve())
+ (m2,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ self.assertTrue(m2["built"])
+ self.assertTrue((Path(m2["dir"]) / "cells.npz").exists())
+
+ def test_a_new_model_rebuilds_the_area(self):
+ from unittest import mock
+ (m,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ with mock.patch.object(RF, "MODEL", RF.MODEL + 1):
+ (m2,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ self.assertNotEqual(m2["key"], m["key"])
+ self.assertTrue(m2["built"])
+ self.assertEqual(m2["model"], RF.MODEL + 1)
+
+ def test_unused_areas_and_links_go(self):
+ (m,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ write_regions(self.reg, square(self.at[0] + 5.0, self.at[1] + 5.0, 2)) # redrawn elsewhere
+ (m2,) = RF.build_areas(self.root, 2, self.reg, log=lambda s: None, regions_root=self.res)
+ self.assertFalse(Path(m["dir"]).is_symlink() or Path(m["dir"]).exists())
+ self.assertEqual([p.name for p in RF.areas_dir(self.res).iterdir() if p.is_dir()], [m2["key"]])
+
+
+class SeaFloorRefineTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ from unittest import mock
+ import seafloor as SF
+ cls.root = built_world()
+ w = RF.WorldCells(cls.root / "out" / "r2")
+ a = w.a
+ river, ocean = a["river"].astype(bool), a["ocean"].astype(bool)
+ mouth = river & ocean[a["recv"]] & ~ocean
+ i = int(np.argmax(np.where(mouth, a["discharge_km3_yr"], -1.0)))
+ cls.at = (float(a["g_lat"][i]), float(a["g_lon"][i]))
+ (cells,) = RF.area_cells([square(*cls.at, 3)], 4)
+ cls.g, cls.halo = RF.subgrid(cells, 12742.0)
+ src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+ cfg = C.load(cls.root)[0]
+ cls.r = RF.refine_area(cls.g, cls.halo, w, src, cfg)
+ real = SF.run
+
+ def untouched(g, z, *args): # the same pass, heights left as they were
+ _, f, v = real(g, z, *args)
+ return np.asarray(z, dtype=np.float64), f, v
+ with mock.patch.object(SF, "run", side_effect=untouched):
+ cls.r0 = RF.refine_area(cls.g, cls.halo, w, src, cfg)
+
+ def test_the_pass_changes_only_the_sea(self):
+ r, r0 = self.r, self.r0
+ np.testing.assert_array_equal(r["ocean"], r0["ocean"])
+ land = ~r0["ocean"]
+ np.testing.assert_array_equal(r["elevation_eroded_m"][land], r0["elevation_eroded_m"][land])
+ np.testing.assert_array_equal(r["river"], r0["river"])
+ np.testing.assert_array_equal(r["elevation_eroded_m"][self.halo], r0["elevation_eroded_m"][self.halo])
+ sea = r["ocean"] & ~self.halo
+ self.assertTrue(sea.any(), "the square reaches the sea")
+ self.assertTrue(np.all(r["elevation_eroded_m"][sea] <= np.maximum(r0["elevation_eroded_m"][sea], -5.0)))
+
+ def test_vents_and_sea_fields_only_in_the_sea(self):
+ r = self.r
+ cells = np.searchsorted(self.g.ids, r["vents"]["cell"])
+ self.assertTrue(np.all(r["ocean"][cells]))
+ land = ~r["ocean"]
+ self.assertTrue(np.all(r["seabed_type"][land] == 0) and np.all(r["vent"][land] == 0))
+ for k in ("seabed_type", "seabed_mineral", "bottom_temp_c", "sediment_m", "vent", "canyon_m", "fan_m",
+ "plateau_id"):
+ self.assertEqual(len(r[k]), self.g.n, k)
+
+ def test_build_saves_the_vents(self):
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(*self.at, 3, "coast"))
+ (m,) = RF.build_areas(self.root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ self.assertEqual(m["model"], RF.MODEL)
+ self.assertIn("vents", m)
+ self.assertIn("canyon_max_m", m)
+ with np.load(Path(m["dir"]) / "vents.npz") as z:
+ self.assertEqual(set(z.files), {"cell", "lat", "lon", "type", "temp_c", "flow", "mineral"})
+ self.assertEqual(len(z["cell"]), m["vents"])
+
+ def test_preview_of_a_refined_region(self):
+ import seafloor as SF
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(*self.at, 3, "coast"))
+ RF.build_areas(self.root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ d = SF.preview_region(self.root, 2, "coast", regions_path=reg, regions_root=tmp / "res", out_dir=tmp / "prev")
+ self.assertGreater((d / "coast-base.jpg").stat().st_size, 1000)
+ with self.assertRaises(SystemExit):
+ SF.preview_region(self.root, 2, "nowhere", regions_path=reg, regions_root=tmp / "res", out_dir=tmp / "prev")
+
+
+class PlateauOutlineTest(unittest.TestCase):
+ def test_outline_holds_the_plateau_and_its_margin(self):
+ from mapgen import plateaus as PL
+ from mapgen.sphere import gc_dist_km, latlon_to_xyz
+ from tests.helpers import small_grid
+ p = {"name": "east-flank", "center": [-39.9, -18.0], "area_km2": 3.0e6, "elongation": 1.9,
+ "azimuth_deg": 14.6, "top_m": [1500.0, 3000.0]}
+ o = RF.plateau_outline(p, 7, 12742.0)
+ self.assertEqual(len(o), 96)
+ self.assertFalse(serve.RegionStore._crosses([(lo, la) for la, lo in o]), "a simple polygon")
+ cells = np.array(sorted(h3.polygon_to_cells(h3.LatLngPoly([tuple(q) for q in o]), 3)), dtype=np.uint64)
+ g = small_grid(3)
+ pid = PL.cell_ids(g.xyz, [p], 7, 12742.0)
+ self.assertGreater(float(np.isin(g.ids[pid == 0], cells).mean()), 0.99)
+ q = np.array(o)
+ d = gc_dist_km(latlon_to_xyz(q[:, 0], q[:, 1]), latlon_to_xyz(*p["center"]), 12742.0)
+ self.assertTrue(np.all(d >= 300.0))
+
+
+class FastPathsTest(unittest.TestCase):
+ """Speed-ups with the same results as the per-cell h3 calls and loops they replaced."""
+
+ def test_cell_parents_match_h3(self):
+ rng = np.random.default_rng(1)
+ base = [c for r0 in h3.get_res0_cells() for c in h3.cell_to_children(r0, 1)]
+ for res_child, res_parent in ((7, 5), (4, 2), (5, 5), (3, 0)):
+ cells = np.array([h3.cell_to_center_child(int(c), res_child) for c in rng.choice(base, 50)]
+ + [h3.cell_to_children(int(c), res_child)[-1] for c in rng.choice(base, 50)], np.uint64)
+ want = [h3.cell_to_parent(int(c), res_parent) for c in cells]
+ np.testing.assert_array_equal(RF.cell_parents(cells, res_parent), np.array(want, np.uint64))
+ with self.assertRaises(ValueError):
+ RF.cell_parents(np.array([h3.cell_to_center_child(int(base[0]), 3)], np.uint64), 4)
+
+ def test_k_mult_matches_the_lookup(self):
+ age = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, -1, 3.9, 9], dtype=np.float64)
+ want = np.vectorize(lambda c: RF.K_MULT.get(int(c), 1.0))(age).astype(np.float64)
+ np.testing.assert_array_equal(RF.k_mult(age), want)
+ np.testing.assert_array_equal(RF.k_mult(age.astype(np.int8)[:9]), want[:9])
+
+ def test_subgrid_in_parallel_is_the_serial_grid(self):
+ from unittest import mock
+ import h3par
+ (cells,) = RF.area_cells([square(10, 20, 4)], 4)
+ g1, h1 = RF.subgrid(cells, 12742.0)
+ with mock.patch.object(h3par, "MIN_PARALLEL", 1), mock.patch.dict("os.environ", {"MAPVIEW_H3_WORKERS": "3"}):
+ g2, h2 = RF.subgrid(cells[::-1].copy(), 12742.0) # unsorted input too
+ for k in ("ids", "lat", "lon", "xyz", "area_km2", "nbr_ptr", "nbr_idx"):
+ np.testing.assert_array_equal(getattr(g1, k), getattr(g2, k))
+ np.testing.assert_array_equal(h1, h2)
diff --git a/tests/test_rivers.py b/tests/test_rivers.py
new file mode 100644
index 0000000..2fb0d42
--- /dev/null
+++ b/tests/test_rivers.py
@@ -0,0 +1,266 @@
+import json
+import unittest
+
+import numpy as np
+from scipy.spatial import cKDTree
+
+import rivers as RV
+from tests.test_serve import built_world
+
+
+def net_and_arrays():
+ root = built_world()
+ d = dict(np.load(root / "out" / "r2" / "cells.npz"))
+ meta = json.loads((root / "out" / "r2" / "cells_meta.json").read_text())
+ return RV.RiverNet(d, meta["legends"], meta["radius_km"]), d
+
+
+class RiverNetTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.net, cls.a = net_and_arrays()
+ cls.r = np.where(cls.a["river"].astype(bool))[0]
+
+ def test_levels_never_above_ground_or_below_the_cap(self):
+ z, lev = self.a["z_surface_m"][self.r], self.net.level[self.r]
+ self.assertTrue(np.all(np.isfinite(lev)))
+ self.assertTrue(np.all(lev <= z + 1e-6))
+ self.assertTrue(np.all(z - lev <= self.net.cap[self.r] + 1e-6))
+
+ def test_levels_never_rise_downstream(self):
+ recv, riv = self.a["recv"], self.a["river"].astype(bool)
+ down = recv[self.r]
+ on = riv[down]
+ self.assertTrue(np.all(self.net.level[down[on]] <= self.net.level[self.r[on]] + 1e-6))
+
+ def test_levels_meet_their_mouth(self):
+ recv, ocean, lake = self.a["recv"], self.a["ocean"].astype(bool), self.a["lake"].astype(bool)
+ sea = self.r[ocean[recv[self.r]]]
+ self.assertTrue(len(sea) > 0)
+ k = np.isin(self.net.seg_a, sea)
+ np.testing.assert_allclose(self.net.level_b[k], 0.0)
+ lk = self.r[lake[recv[self.r]]]
+ k = np.isin(self.net.seg_a, lk)
+ np.testing.assert_allclose(self.net.level_b[k], self.a["z_filled_m"][recv[self.net.seg_a[k]]])
+
+ def test_drops_below_lakes_are_cataracts_not_cliffs(self):
+ recv, riv, lake = self.a["recv"], self.a["river"].astype(bool), self.a["lake"].astype(bool)
+ src = np.where(lake & riv[recv] & (recv != np.arange(len(recv))))[0]
+ for i in src:
+ j = recv[i]
+ dist = np.linalg.norm(self.a["g_xyz"][i] - self.a["g_xyz"][j]) * self.net.R
+ drop = self.a["z_filled_m"][i] - self.net.level[j]
+ self.assertLessEqual(drop, RV.KNICK_M_KM * dist + 1e-6 + max(0.0, self.a["z_filled_m"][i] - self.a["z_surface_m"][j]))
+
+ def test_geometry_per_segment(self):
+ n = len(self.net.seg_a) # river cells + lake outlets
+ self.assertGreaterEqual(n, len(self.r))
+ for k in ("a_xyz", "b_xyz", "level_a", "level_b", "half_w", "floor", "wall", "meander", "reach", "seg_len"):
+ self.assertEqual(len(getattr(self.net, k)), n, k)
+ self.assertTrue(np.all(self.net.half_w >= 0.03) and np.all(self.net.wall > 0))
+
+
+class StyleTest(unittest.TestCase):
+ def test_hard_dry_rock_makes_steeper_narrower_valleys(self):
+ g = RV.valley_style("plateau", "—", "granite/gneiss", 400.0)
+ s = RV.valley_style("plateau", "—", "sandstone/shale", 1800.0)
+ self.assertGreater(g[1], 3 * s[1]) # walls
+ self.assertLess(g[0], s[0]) # floor
+ self.assertGreater(g[1], 2500) # the granite gorge: sheer
+
+ def test_floodplains_are_wide_and_meander(self):
+ f = RV.valley_style("plain", "floodplain", "sandstone/shale", 900.0)
+ m = RV.valley_style("mountains", "—", "granite/gneiss", 900.0)
+ self.assertGreater(f[0], 10 * m[0])
+ self.assertGreater(f[2], 0)
+ self.assertEqual(m[2], 0)
+
+ def test_channel_width(self):
+ self.assertAlmostEqual(RV.half_width_km(2.0), 0.004 * np.sqrt(2.0 * 31.7), places=9)
+
+
+class ValleyTest(unittest.TestCase):
+ def test_profile(self):
+ d = np.array([0.0, 0.05, 0.2, 1.0, 2.0])
+ v = RV.valley_surface(d, 100.0, 0.1, 0.3, 2000.0)
+ self.assertEqual(v[0], 100.0) # channel: the water level
+ self.assertEqual(v[1], 100.0)
+ self.assertTrue(101.0 <= v[2] <= 103.0) # floor
+ self.assertAlmostEqual(v[4] - v[3], 2000.0, places=6) # wall: 2,000 m per km
+
+ def test_points_are_a_global_lattice(self):
+ net, _ = net_and_arrays()
+ s = int(np.argmax(net.seg_len))
+ c = net.a_xyz[s]
+ p1, _ = net.segment_points(s, c, 50.0, 5.0)
+ p2, _ = net.segment_points(s, c, 400.0, 5.0)
+ self.assertGreater(len(p2), len(p1))
+ a = {tuple(np.round(v, 12)) for v in p1}
+ b = {tuple(np.round(v, 12)) for v in p2}
+ self.assertTrue(a <= b, "a wider query returns a superset of the same points")
+
+ def test_antimeridian_segment(self):
+ net, _ = net_and_arrays()
+ a = np.array([np.cos(np.radians(10)) * np.cos(np.radians(179.9)), np.cos(np.radians(10)) * np.sin(np.radians(179.9)), np.sin(np.radians(10))])
+ b = np.array([np.cos(np.radians(10)) * np.cos(np.radians(-179.9)), np.cos(np.radians(10)) * np.sin(np.radians(-179.9)), np.sin(np.radians(10))])
+ pts = RV.densify(a, b, 0.0, 1.0, 1.0 / net.R) # 1 km steps (a chord in planet radii)
+ self.assertTrue(np.all(np.abs(np.linalg.norm(pts, axis=1) - 1) < 1e-12))
+ self.assertLess(np.max(np.linalg.norm(np.diff(pts, axis=0), axis=1)) * net.R, 1.01)
+
+
+class RoughWallTest(unittest.TestCase):
+ def test_walls_keep_the_terrain_roughness_but_the_channel_stays_flat(self):
+ d = np.array([0.0, 0.3, 1.0, 2.0])
+ smooth = RV.valley_surface(d, 100.0, 0.1, 0.1, 500.0)
+ rough = RV.valley_surface(d, 100.0, 0.1, 0.1, 500.0, rough=np.array([80.0, 80.0, 80.0, -40.0]))
+ self.assertEqual(rough[0], smooth[0]) # the water level
+ self.assertAlmostEqual(rough[2] - smooth[2], 80.0) # a wall well away from the floor: full roughness
+ self.assertAlmostEqual(rough[3] - smooth[3], -40.0)
+ self.assertTrue(0 < rough[1] - smooth[1] < 80.0) # fading in above the floor
+
+
+LEG = {"landform": ["ocean", "plain", "hills"], "ground": ["—", "floodplain"], "lithology": ["granite/gneiss", "sandstone/shale"],
+ "age_class": ["oceanic", "craton (3g era)"]}
+
+
+def synthetic(cells):
+ """A tiny world. cells: (lat, lon, z, discharge, landform, ground, receiver index, kind: river|ocean|lake|land)."""
+ ll = np.radians([(c[0], c[1]) for c in cells])
+ a = {"g_xyz": np.stack([np.cos(ll[:, 0]) * np.cos(ll[:, 1]), np.cos(ll[:, 0]) * np.sin(ll[:, 1]), np.sin(ll[:, 0])], 1),
+ "z_surface_m": np.array([c[2] for c in cells], float), "z_filled_m": np.array([c[2] for c in cells], float),
+ "discharge_km3_yr": np.array([c[3] for c in cells], float),
+ "landform": np.array([LEG["landform"].index(c[4]) for c in cells]), "ground": np.array([LEG["ground"].index(c[5]) for c in cells]),
+ "recv": np.array([c[6] for c in cells]), "river": np.array([c[7] == "river" for c in cells]),
+ "ocean": np.array([c[7] == "ocean" for c in cells]), "lake": np.array([c[7] == "lake" for c in cells]),
+ "lithology": np.ones(len(cells), int), "age_class": np.ones(len(cells), int), "P_ann": np.full(len(cells), 900.0)}
+ return RV.RiverNet(a, LEG, 12742.0)
+
+
+def xyz(lat, lon):
+ la, lo = np.radians(lat), np.radians(lon)
+ return np.array([[np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)]])
+
+
+class PerValleyTest(unittest.TestCase):
+ # a big floodplain river along the equator (east to the sea) and a small hill stream 22 km north joining it
+ net = synthetic([(0, 0.0, 100, 5000, "plain", "floodplain", 1, "river"), (0, 0.3, 90, 5000, "plain", "floodplain", 2, "river"),
+ (0, 0.6, 80, 5000, "plain", "floodplain", 3, "river"), (0, 0.9, 70, 5000, "plain", "floodplain", 4, "river"),
+ (0, 1.2, -50, 0, "ocean", "—", 4, "ocean"),
+ (0.1, 0.3, 400, 2, "hills", "—", 6, "river"), (0.1, 0.6, 380, 2, "hills", "—", 2, "river")])
+
+ def test_a_big_valley_is_not_crowded_out_by_a_nearby_stream(self):
+ q = xyz(0.05, 0.45) # 11 km from both centrelines
+ V, ch, dr = self.net.valleys(q, 0.5)
+ big = float(np.interp(0.45, [0.3, 0.6], [self.net.level[1], self.net.level[2]]))
+ self.assertLessEqual(V[0], big + 3.0 + 1e-6, "on the big river's floodplain floor")
+
+ def test_the_answer_does_not_depend_on_the_other_query_points(self):
+ q = xyz(0.05, 0.45)
+ many = np.vstack([q, xyz(3.0, 20.0), xyz(-0.2, 0.1), xyz(0.08, 0.5)])
+ a, b = self.net.valleys(q, 0.5)[0], self.net.valleys(many, 0.5)[0]
+ self.assertEqual(a[0], b[0])
+
+ def test_floors_stay_inside_the_reach(self):
+ self.assertTrue(np.all(self.net.floor <= RV.FLOOR_MAX_KM + 1e-9))
+ self.assertTrue(np.all(self.net.reach >= self.net.half_w + self.net.floor))
+
+ def test_rough_walls_never_dip_below_the_water(self):
+ v = RV.valley_surface(np.array([0.6, 1.0]), 100.0, 0.1, 0.3, 500.0, rough=np.array([-500.0, -500.0]))
+ self.assertTrue(np.all(v >= 101.0))
+
+
+class BankTest(unittest.TestCase):
+ def test_a_river_doubling_back_below_itself_keeps_its_banks_above_its_water(self):
+ # a canyon river runs east, then back west 5.5 km north and 400 m lower: its lower floor must not
+ # undercut the upper reach (dry pits below the water beside it)
+ net = synthetic([(0, 0.0, 900, 300, "hills", "—", 1, "river"), (0, 0.3, 700, 300, "hills", "—", 2, "river"),
+ (0.05, 0.0, 300, 300, "hills", "—", 3, "river"), (0.05, -0.3, -50, 0, "ocean", "—", 3, "ocean")])
+ la, lo = np.meshgrid(np.linspace(-0.03, 0.08, 40), np.linspace(-0.05, 0.35, 80), indexing="ij")
+ q = np.vstack([xyz(a, b) for a, b in zip(la.ravel(), lo.ravel())])
+ V, ch, _ = net.valleys(q, 0.1)
+ best, lev, bank = np.full(len(q), np.inf), np.zeros(len(q)), np.zeros(len(q), bool)
+ c = q.mean(0) / np.linalg.norm(q.mean(0))
+ for s in range(len(net.seg_a)): # the nearest reach, its water, its floor zone
+ p, t = net.segment_points(s, c, 60.0, 0.05)
+ d, k = cKDTree(p).query(q)
+ d *= net.R
+ b = d < best
+ best[b], bank[b] = d[b], (d <= net.half_w[s] + net.floor[s])[b]
+ lev[b] = (net.level_a[s] + (net.level_b[s] - net.level_a[s]) * t[k])[b]
+ dry = ~ch & bank
+ self.assertGreater(dry.sum(), 100)
+ self.assertEqual(int((V[dry] < lev[dry]).sum()), 0, "floor below the water of the reach beside it")
+
+
+class LakeOutletTest(unittest.TestCase):
+ def test_a_lake_outlet_joins_its_river_starting_at_the_lake_surface(self):
+ net = synthetic([(0, 0.0, 1100, 0, "hills", "—", 1, "lake"), (0, 0.3, 1100, 300, "plain", "—", 2, "river"),
+ (0, 0.6, 600, 300, "plain", "—", 3, "river"), (0, 0.9, -50, 0, "ocean", "—", 3, "ocean")])
+ k = np.where(net.seg_a == 0)[0]
+ self.assertEqual(len(k), 1, "the lake outlet has a segment")
+ self.assertEqual(net.level_a[k[0]], 1100.0)
+ self.assertEqual(net.seg_b[k[0]], 1)
+
+
+class GroundTest(unittest.TestCase):
+ def test_given_levels_reach_their_shoulders_and_sub_pixel_streams_are_not_drawn(self):
+ cells = [(0, 0.0, 800, 5000, "plateau", "—", 1, "river"), (0, 0.3, 700, 5000, "plateau", "—", 2, "river"),
+ (0, 0.6, -50, 0, "ocean", "—", 2, "ocean")]
+ levels = np.array([400.0, 300.0, 0.0])
+ ground = np.array([800.0, 700.0, 0.0])
+ LEG2 = dict(LEG, landform=LEG["landform"] + ["plateau"])
+ ll = np.radians([(c[0], c[1]) for c in cells])
+ arr = {"g_xyz": np.stack([np.cos(ll[:, 0]) * np.cos(ll[:, 1]), np.cos(ll[:, 0]) * np.sin(ll[:, 1]), np.sin(ll[:, 0])], 1),
+ "z_surface_m": ground.copy(), "z_filled_m": ground.copy(), "discharge_km3_yr": np.array([5000.0, 5000.0, 0.0]),
+ "landform": np.array([3, 3, 0]), "ground": np.zeros(3, int), "recv": np.array([1, 2, 2]),
+ "river": np.array([True, True, False]), "ocean": np.array([False, False, True]), "lake": np.zeros(3, bool),
+ "lithology": np.zeros(3, int), "age_class": np.ones(3, int), "P_ann": np.full(3, 300.0)}
+ net = RV.RiverNet(arr, LEG2, 12742.0, levels=levels, ground=ground)
+ k = 0
+ self.assertGreaterEqual(net.reach[k], net.half_w[k] + net.floor[k] + (ground[0] - levels[0]) / net.wall[k] - 1e-9)
+ small = synthetic([(0, 0.0, 100, 0.2, "plain", "—", 1, "river"), (0, 0.3, 90, 0.2, "plain", "—", 2, "river"),
+ (0, 0.6, -50, 0, "ocean", "—", 2, "ocean")])
+ q = xyz(0.0, 0.15)
+ self.assertFalse(small.channel(q, 5.0)[1][0], "a 0.2 km³/yr stream is not drawn at 5 km per pixel")
+ self.assertTrue(small.channel(q, 0.01)[1][0], "but is at street level")
+
+
+class ChunkTest(unittest.TestCase):
+ def test_a_coarse_tile_splits_into_compact_chunks(self):
+ net = synthetic([(0, 0.0, 100, 5, "plain", "—", 1, "river"), (0, 0.3, 90, 5, "plain", "—", 2, "river"),
+ (0, 0.6, -50, 0, "ocean", "—", 2, "ocean")])
+ la, lo = np.meshgrid(np.linspace(-52.0, -46.4, 256), np.linspace(-45.0, -33.75, 256), indexing="ij") # a z5 tile
+ a, o = np.radians(la.ravel()), np.radians(lo.ravel())
+ chunks = net._chunks(np.stack([np.cos(a) * np.cos(o), np.cos(a) * np.sin(o), np.sin(a)], 1))
+ self.assertLess(len(chunks), 300, "not thousands of one-row strips")
+ self.assertEqual(sorted(np.concatenate([c[0] for c in chunks]).tolist()), list(range(256 * 256)))
+
+
+class RiverNetStateTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import refine as RF
+ from tests.test_serve import built_world
+ w = RF.WorldCells(built_world() / "out" / "r2")
+ cls.a = {k: np.asarray(w.a[k]) for k in RF.NET_KEYS}
+ cls.legends, cls.R = w.meta["legends"], float(w.meta["radius_km"])
+
+ def test_state_round_trip_answers_the_same(self):
+ import rivers as RV
+ net = RV.RiverNet(self.a, self.legends, self.R)
+ arrays, meta = net.state()
+ self.assertEqual(set(arrays), set(RV.STATE))
+ back = RV.RiverNet.from_state(arrays, meta)
+ for k in RV.STATE:
+ np.testing.assert_array_equal(getattr(back, k), getattr(net, k))
+ self.assertEqual(back.max_extent, net.max_extent)
+ p = np.asarray(self.a["g_xyz"][:200], dtype=np.float64)
+ np.testing.assert_array_equal(back.valleys(p, 5.0)[1], net.valleys(p, 5.0)[1])
+ np.testing.assert_array_equal(back.channel(p, 5.0)[1], net.channel(p, 5.0)[1])
+
+ def test_tree_is_built_on_first_use(self):
+ import rivers as RV
+ net = RV.RiverNet.from_state(*RV.RiverNet(self.a, self.legends, self.R).state())
+ self.assertIsNone(net._tree)
+ self.assertIsNotNone(net.tree)
+ self.assertIs(net.tree, net.tree)
diff --git a/tests/test_seafloor.py b/tests/test_seafloor.py
new file mode 100644
index 0000000..fed467d
--- /dev/null
+++ b/tests/test_seafloor.py
@@ -0,0 +1,195 @@
+import unittest
+
+import numpy as np
+
+import refine as RF
+import seafloor as SF
+from mapgen.graph import priority_flood
+from mapgen.sphere import gc_dist_km, latlon_to_xyz
+from tests.test_refine import square
+
+R = 12742.0
+
+
+def grid(lat=0.0, lon=0.0, d=4.0):
+ (cells,) = RF.area_cells([square(lat, lon, d)], 5)
+ return RF.subgrid(cells, R)
+
+
+def shelf():
+ """A res-5 square across a coast: land west of 3° W, a shelf, a slope with gullies, an abyssal plain with a deep
+ hollow (≈ 300 m, at 0°, 2.5° E) and a shallow one (≈ 30 m, at 2.5° S, 1.5° E)."""
+ g, halo = grid()
+ x = g.lon
+ z = np.where(x < -3.0, 200.0,
+ np.where(x < -1.5, -100.0 - 100.0 * (x + 3.0) / 1.5,
+ np.where(x < 0.0, -200.0 - 3800.0 * (x + 1.5) / 1.5, -4000.0 - 75.0 * x)))
+ z = z + np.where((x >= -1.5) & (x < 0.0), 40.0 * np.sin(np.radians(g.lat) * 400.0), 0.0) # gullies
+ for la, lo, r_km, depth in ((0.0, 2.5, 60.0, 300.0), (-2.5, 1.5, 40.0, 30.0)):
+ d = gc_dist_km(g.xyz, latlon_to_xyz(la, lo), R)
+ z = z - depth * np.clip(1.0 - (d / r_km) ** 2, 0.0, 1.0)
+ return g, halo, z, z <= 0
+
+
+class CanyonTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.g, cls.halo, cls.z, cls.sea = shelf()
+ cls.zc, cls.cut, cls.fan = SF.canyons(cls.g, cls.z, cls.sea, cls.halo)
+
+ def test_canyons_cut_the_slope(self):
+ self.assertGreater(self.cut.max(), 50.0)
+ self.assertLessEqual(self.cut.max(), SF.CANYON["cap_m"])
+ slope = (self.g.lon > -1.5) & (self.g.lon < 0.0)
+ self.assertGreater(self.cut[slope].max(), self.cut[~slope].max(initial=0.0))
+
+ def test_only_lower_in_canyons_and_raise_at_most_100_m_on_fans(self):
+ dz = self.zc - self.z
+ self.assertGreaterEqual(dz.min(), -SF.CANYON["cap_m"] - 1e-9)
+ self.assertLessEqual(dz.max(), SF.FAN["max_m"] + 1e-9)
+ np.testing.assert_array_equal(dz < 0, self.cut > 0)
+ self.assertTrue(np.all(self.fan[dz > 0] > 0))
+ self.assertGreater(self.fan.max(), 0.0, "fans where the flows stop")
+
+ def test_land_and_halo_stay(self):
+ keep = ~self.sea | self.halo
+ np.testing.assert_array_equal(self.zc[keep], self.z[keep])
+ self.assertTrue(np.all(self.zc[self.sea] <= np.maximum(self.z[self.sea], SF.SEA_TOP_M)))
+
+ def test_deterministic(self):
+ again = SF.canyons(self.g, self.z, self.sea, self.halo)
+ for a, b in zip(again, (self.zc, self.cut, self.fan)):
+ np.testing.assert_array_equal(a, b)
+
+
+class PondTest(unittest.TestCase):
+ def setUp(self):
+ self.g, self.halo, self.z, self.sea = shelf()
+ self.sed = np.full(self.g.n, 500.0)
+
+ def test_deep_hollows_fill_flat_below_their_spill(self):
+ zp, fill = SF.ponds(self.g, self.z, self.sea, self.halo, self.sed)
+ zf = priority_flood(self.g, np.where(self.sea, self.z, SF.WALL_M), self.sea & self.halo, eps=0.0)
+ self.assertTrue(np.all(zp <= np.maximum(zf, self.z) + 1e-9), "never above the spill level")
+ self.assertTrue(np.all(zp >= self.z))
+ np.testing.assert_allclose(fill, zp - self.z)
+ deep = gc_dist_km(self.g.xyz, latlon_to_xyz(0.0, 2.5), R) < 30.0
+ self.assertTrue(np.all(fill[deep] > 0))
+ self.assertLess(float(np.ptp(zp[deep])), 1e-6, "a flat floor")
+ self.assertAlmostEqual(float(zp[deep][0]), float(self.z[deep].min()) + 0.3 * 500.0, delta=1.0)
+ shallow = gc_dist_km(self.g.xyz, latlon_to_xyz(-2.5, 1.5), R) < 20.0
+ self.assertTrue(np.all(fill[shallow] == 0), "hollows under 50 m stay as they are")
+
+ def test_no_sea_is_a_no_op(self):
+ z = np.full(self.g.n, 300.0)
+ sea = np.zeros(self.g.n, bool)
+ zc, cut, fan = SF.canyons(self.g, z, sea, self.halo)
+ zp, fill = SF.ponds(self.g, z, sea, self.halo, self.sed)
+ np.testing.assert_array_equal(zc, z)
+ np.testing.assert_array_equal(zp, z)
+ self.assertFalse(cut.any() or fan.any() or fill.any())
+
+ def test_sea_without_halo_sea_still_ponds(self):
+ d = gc_dist_km(self.g.xyz, latlon_to_xyz(0.0, 0.0), R)
+ z = np.where(d < 200.0, -1000.0 - 300.0 * np.clip(1.0 - (d / 100.0) ** 2, 0.0, 1.0), 500.0)
+ sea = z <= 0
+ self.assertFalse((sea & self.halo).any())
+ zp, fill = SF.ponds(self.g, z, sea, self.halo, self.sed)
+ zc, _, _ = SF.canyons(self.g, z, sea, self.halo)
+ self.assertTrue(np.all(np.isfinite(zp)) and np.all(np.isfinite(zc)))
+
+
+from mapgen import plateaus as PL, seabed as SB
+
+PLAT = {"name": "p", "center": [0.0, 0.0], "area_km2": 2.0e5, "top_m": [1500.0, 2500.0]}
+
+
+def world_values(n=1):
+ return {"vent_potential": np.full(n, 0.8), "seabed_type": np.full(n, SB.SB_CONTINENTAL, np.int8),
+ "seabed_mineral": np.zeros(n, np.int8), "bottom_temp_c": np.full(n, 3.0),
+ "sediment_m": np.full(n, 400.0), "d_div_km": np.full(n, 900.0), "T_mean": np.full(n, 10.0)}
+
+
+class CellDrawTest(unittest.TestCase):
+ def test_cell_draws_are_uniform_and_stable(self):
+ ids = np.arange(10 ** 5, dtype=np.uint64) * np.uint64(7919) + np.uint64(612345678901)
+ u = SF._u(ids, 3)
+ self.assertTrue(0.0 <= u.min() and u.max() < 1.0)
+ self.assertAlmostEqual(float(u.mean()), 0.5, delta=0.01)
+ np.testing.assert_array_equal(SF._u(ids, 3), u)
+ self.assertFalse(np.array_equal(SF._u(ids, 4), u))
+
+
+class VolcanicTest(unittest.TestCase):
+ def test_fine_features_on_the_plateau_only(self):
+ g, halo = grid()
+ pid = PL.cell_ids(g.xyz, [PLAT], 7, R)
+ add, near = SF.volcanic(g, np.ones(g.n, bool), halo, pid, [PLAT], 7)
+ self.assertGreater(add.max(), 150.0)
+ self.assertTrue(np.all(add[pid < 0] == 0) and np.all(near[pid < 0] == 0))
+ self.assertTrue(np.all(add[halo] == 0))
+
+ def test_features_and_vents_do_not_depend_on_the_area(self):
+ outs = []
+ for g, h in (grid(0.0, 0.0, 4.0), grid(0.5, 0.5, 3.0)):
+ pid = PL.cell_ids(g.xyz, [PLAT], 7, R)
+ sea = np.ones(g.n, bool)
+ add, near = SF.volcanic(g, sea, h, pid, [PLAT], 7)
+ _, v = SF.vents(g, np.full(g.n, -2000.0) + add, sea, h, np.full(g.n, 0.8), near, np.full(g.n, 500.0),
+ np.full(g.n, 100.0), np.full(g.n, 2.0), 7)
+ outs.append((g, h, add, v))
+ (ga, ha, aa, va), (gb, hb, ab, vb) = outs
+ ca, cb = np.flatnonzero(~ha), np.flatnonzero(~hb)
+ common, ia, ib = np.intersect1d(ga.ids[ca], gb.ids[cb], return_indices=True)
+ self.assertGreater(len(common), 100)
+ np.testing.assert_allclose(aa[ca[ia]], ab[cb[ib]], atol=1e-9)
+ np.testing.assert_array_equal(np.intersect1d(va["cell"], common), np.intersect1d(vb["cell"], common))
+
+ def test_hidden_plateaus_stay_500_m_down_and_no_sea_rises_to_land(self):
+ g, halo = grid()
+ z = np.full(g.n, -600.0)
+ sea = np.ones(g.n, bool)
+ zn, f, v = SF.run(g, z, sea, halo, np.zeros(g.n, np.int64), world_values(), [PLAT], 7)
+ on = (f["plateau_id"] >= 0) & ~halo
+ self.assertTrue(on.any())
+ self.assertLessEqual(float(zn[on].max()), -500.0)
+ isl = {**PLAT, "islands": True}
+ zi, fi, _ = SF.run(g, z, sea, halo, np.zeros(g.n, np.int64), world_values(), [isl], 7)
+ self.assertLessEqual(float(zi.max()), SF.SEA_TOP_M)
+ np.testing.assert_array_equal(zn[halo], z[halo])
+
+
+class VentTest(unittest.TestCase):
+ def test_vents_only_in_the_sea_and_smokers_on_the_ridge(self):
+ g, halo = grid()
+ z = np.where(g.lon < -3.0, 100.0, -2500.0)
+ sea = z <= 0
+ d_div = np.abs(g.lon - 2.0) * 111.0 # a ridge axis along 2° E
+ pot = np.where(sea, np.clip(1.0 - d_div / 300.0, 0.05, 1.0), 0.0)
+ st, v = SF.vents(g, z, sea, halo, pot, np.zeros(g.n), d_div, np.full(g.n, 1500.0), np.full(g.n, 2.0), 7)
+ cells = np.searchsorted(g.ids, v["cell"])
+ self.assertGreater(len(cells), 0)
+ self.assertTrue(np.all(sea[cells] & ~halo[cells] & (z[cells] < -SF.VENT["min_depth_m"])))
+ self.assertTrue(np.all(v["type"][d_div[cells] < SF.VENT["ridge_km"]] == 0), "black smokers on the ridge")
+ t, temp = v["type"], v["temp_c"]
+ self.assertTrue(np.all((temp[t == 0] >= 300) & (temp[t == 0] <= 400)))
+ self.assertTrue(np.all(st[~sea] == 0) and st.max() <= 1.0)
+ np.testing.assert_array_equal(v["mineral"], v["type"])
+
+
+class FieldsTest(unittest.TestCase):
+ def test_fields_zero_on_land_and_features_mark_the_floor(self):
+ pv = {"seabed_type": np.array([SB.SB_CLAY, SB.SB_NONE, SB.SB_CLAY, SB.SB_CLAY, SB.SB_NONE], np.int8),
+ "seabed_mineral": np.zeros(5, np.int8), "bottom_temp_c": np.array([2.0, 0.0, 2.0, 2.0, 0.0]),
+ "sediment_m": np.array([400.0, 0.0, 400.0, 400.0, 0.0]), "T_mean": np.array([10.0, 12.0, 10.0, 10.0, 12.0])}
+ sea = np.array([True, True, True, True, False])
+ z0 = np.zeros(5)
+ f = SF.fields(pv, sea, np.zeros(5, bool), np.array([0.0, 0, 100, 0, 0]), np.array([0.0, 0, 0, 5, 0]), z0,
+ np.array([0.0, 0, 0, 0, 0]), np.array([0.9, 0, 0, 0, 0]))
+ np.testing.assert_array_equal(f["seabed_type"], [SB.SB_VENTS, SB.SB_TERRIGENOUS, SB.SB_TERRIGENOUS,
+ SB.SB_TERRIGENOUS, SB.SB_NONE])
+ self.assertEqual(int(f["seabed_mineral"][0]), SB.MI_SULFIDES)
+ np.testing.assert_allclose(f["sediment_m"], [0.0, 300.0, 120.0, 405.0, 0.0])
+ np.testing.assert_allclose(f["bottom_temp_c"], [20.0, 12.0, 2.0, 2.0, 0.0])
+ np.testing.assert_allclose(f["vent"], [0.9, 0, 0, 0, 0], rtol=1e-6)
+ np.testing.assert_allclose(f["canyon_m"], [0, 0, 100, 0, 0])
diff --git a/tests/test_serve.py b/tests/test_serve.py
new file mode 100644
index 0000000..372e41e
--- /dev/null
+++ b/tests/test_serve.py
@@ -0,0 +1,1346 @@
+import contextlib
+import io
+import http.client
+import json
+import os
+import socket
+import shutil
+import tempfile
+import threading
+import time
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+
+from mapgen import pipeline as P
+from mapgen.testing import built_world, small_world # noqa: F401 (other tests import built_world from here)
+from tests.helpers import big_river
+
+import serve
+
+
+class Running:
+ def __init__(self, server):
+ self.server = server
+
+ def __enter__(self):
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
+ return f"http://127.0.0.1:{self.server.server_address[1]}"
+
+ def __exit__(self, *a):
+ self.server.shutdown()
+ self.server.server_close()
+
+
+def request(url, method="GET", body=None):
+ data = None if body is None else (body if isinstance(body, bytes) else json.dumps(body).encode())
+ req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(req) as r:
+ return r.status, r.read()
+ except urllib.error.HTTPError as e:
+ with e:
+ return e.code, e.read()
+
+
+class HubTest(unittest.TestCase):
+ def test_routes_per_world(self):
+ root = built_world()
+ with Running(serve.make_hub([("a", root, 2), ("b", root, 2)], 0)) as base:
+ status, body = request(base + "/api/worlds")
+ self.assertEqual(status, 200)
+ self.assertEqual([w["id"] for w in json.loads(body)["worlds"]], ["a", "b"])
+ status, body = request(base + "/w/b/api/worlds")
+ self.assertEqual(json.loads(body)["current"], "b")
+ status, body = request(base + "/w/b/api/meta")
+ self.assertEqual(status, 200)
+ self.assertIn("/w/b/", body.decode())
+ self.assertNotIn('"/tiles/', body.decode())
+ self.assertEqual(request(base + "/api/meta")[0], 200)
+ self.assertEqual(request(base + "/w/nope/api/meta")[0], 404)
+ with urllib.request.urlopen(base + "/w/a") as r:
+ self.assertTrue(r.url.endswith("/w/a/"))
+
+
+class CoreTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.pins_dir = Path(tempfile.mkdtemp())
+
+ def tearDown(self):
+ shutil.rmtree(self.pins_dir)
+
+ def server(self):
+ lore = self.pins_dir / "lore"
+ lore.mkdir(exist_ok=True)
+ (lore / "physical-world.md").write_text("---\nid: physical-world\n---\nNotes.\n")
+ return serve.make_server(self.root, 2, 0, pins_path=self.pins_dir / "pins.json", lore_dir=lore)
+
+ def test_meta(self):
+ with Running(self.server()) as base:
+ status, body = request(base + "/api/meta")
+ meta = json.loads(body)
+ self.assertEqual(status, 200)
+ self.assertEqual(meta["res"], 2)
+ self.assertAlmostEqual(meta["radius_km"], 12742.0)
+ self.assertAlmostEqual(meta["units"]["league_km"], 15.4437)
+ self.assertEqual(meta["layers"][0]["id"], "relief")
+ self.assertIn("coast", [o["id"] for o in meta["overlays"]])
+ self.assertIn("physical-world", meta["lore_ids"])
+
+ def test_static_and_traversal(self):
+ with Running(self.server()) as base:
+ s1, html = request(base + "/")
+ s2, _ = request(base + "/data/viewer/relief.jpg")
+ s3, _ = request(base + "/data/geo/coast.geojson")
+ s4, _ = request(base + "/data/viewer/../cells_meta.json")
+ s5, _ = request(base + "/../serve.py")
+ self.assertEqual((s1, s2, s3), (200, 200, 200))
+ self.assertIn(b"World Maps", html)
+ self.assertEqual((s4, s5), (404, 404))
+
+ def test_pick_res_needs_viewer_outputs(self):
+ tmp = Path(tempfile.mkdtemp())
+ try:
+ cfg = {"build": {"res_final": 5, "res_dev": 4}}
+ (tmp / "out" / "r5").mkdir(parents=True)
+ (tmp / "out" / "r5" / "cells.npz").write_bytes(b"")
+ self.assertEqual(serve.pick_res(tmp, cfg), 4) # final cells but no viewer textures yet
+ (tmp / "out" / "r5" / "viewer").mkdir()
+ (tmp / "out" / "r5" / "viewer" / "layers.json").write_text("[]")
+ self.assertEqual(serve.pick_res(tmp, cfg), 5)
+ finally:
+ shutil.rmtree(tmp)
+
+ def test_main_stops_cleanly_on_sigterm(self):
+ import signal
+
+ def term_then_wait(self_):
+ os.kill(os.getpid(), signal.SIGTERM)
+ time.sleep(5) # the handler interrupts this
+ raise AssertionError("SIGTERM did not stop the server")
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ before = signal.getsignal(signal.SIGTERM)
+ with mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", term_then_wait), \
+ contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(serve.main(["--res", "2", "--port", str(port), "--workers", "0"], root=self.root), 0)
+ self.assertEqual(signal.getsignal(signal.SIGTERM), before, "the handler is put back")
+
+ def test_main_workers_option(self):
+ seen = []
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ real = serve.make_server
+
+ def spy(*a, **k):
+ seen.append(k.get("workers"))
+ return real(*a, **{**k, "workers": 0}) # no forking in the test
+ with mock.patch.object(serve, "make_server", spy), \
+ mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(serve.main(["--res", "2", "--port", str(port), "--workers", "3"], root=self.root), 0)
+ self.assertEqual(serve.main(["--res", "2", "--port", str(port)], root=self.root), 0)
+ self.assertEqual(seen, [3, serve.default_workers()])
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ serve.main(["--res", "2", "--workers", "-1"], root=self.root)
+
+ def test_main_prunes_stale_serve_caches_at_start(self):
+ import refine
+ import servecache as SC
+ import tiles as T
+ out = self.root / "out" / "r2"
+ stale_world = SC.cache_dir(out, "stale")
+ stale_world.mkdir(parents=True, exist_ok=True)
+ from mapgen import config as C
+ seed = int(C.load(self.root)[0]["build"]["seed"])
+ fp = T.TileSource(serve.World(self.root, 2), seed=seed, cache_dir=Path(tempfile.mkdtemp())).fingerprint
+ stale_regions = SC.cache_dir(refine.results_dir(out / "regions", fp), "stale")
+ stale_regions.mkdir(parents=True, exist_ok=True)
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ with mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ contextlib.redirect_stdout(io.StringIO()):
+ code = serve.main(["--res", "2", "--port", str(port)], root=self.root)
+ self.assertEqual(code, 0)
+ self.assertFalse(stale_world.exists())
+ self.assertFalse(stale_regions.exists())
+ self.assertTrue(any(out.glob(f"{SC.PREFIX}*")), "the world's own cache stays")
+
+ def test_main_carries_saved_tiles_over_before_rendering_ahead(self):
+ import tiles as T
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ calls = []
+ with mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ mock.patch.object(T.TileSource, "carry_stale", lambda src: calls.append("carry")), \
+ mock.patch.object(serve.RefineJobs, "prerender", lambda jobs: calls.append("prerender")), \
+ contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(serve.main(["--res", "2", "--port", str(port)], root=self.root), 0)
+ self.assertEqual(calls[-1], "prerender")
+ self.assertIn("carry", calls)
+
+ def test_port_in_use_is_a_clear_error(self):
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ s.listen()
+ port = s.getsockname()[1]
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ code = serve.main(["--res", "2", "--port", str(port)], root=self.root)
+ self.assertEqual(code, 2)
+ self.assertIn(f"port {port} is in use", err.getvalue())
+
+ def test_failed_setup_frees_the_port(self):
+ made = []
+
+ class Kept(serve.ThreadingHTTPServer): # keeps the instance alive: garbage collection can't hide a leak
+ def __init__(self, *a, **k):
+ super().__init__(*a, **k)
+ made.append(self)
+ with mock.patch("serve.ThreadingHTTPServer", Kept), \
+ mock.patch("tiles.TileSource", side_effect=RuntimeError("no rasters")), self.assertRaises(RuntimeError):
+ serve.make_server(self.root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")
+ self.assertEqual(made[0].socket.fileno(), -1, "socket closed")
+
+ def test_port_in_use_reported_before_loading_the_world(self):
+ import time
+ real = serve.World
+ def slow(*a, **k):
+ time.sleep(3)
+ return real(*a, **k)
+ with socket.socket() as s, mock.patch("serve.World", side_effect=slow):
+ s.bind(("127.0.0.1", 0))
+ s.listen()
+ t = time.time()
+ with contextlib.redirect_stderr(io.StringIO()):
+ code = serve.main(["--res", "2", "--port", str(s.getsockname()[1])], root=self.root)
+ self.assertEqual(code, 2)
+ self.assertLess(time.time() - t, 1.5)
+
+ def test_missing_cell_is_404_not_a_neighbour(self):
+ server = self.server()
+ i = server.world.index_of(10.0, 20.0)
+ server.world.ids = np.delete(server.world.ids, i)
+ with self.assertRaises(LookupError):
+ server.world.index_of(10.0, 20.0)
+ with Running(server) as base:
+ self.assertEqual(request(f"{base}/api/cell?lat=10&lon=20")[0], 404)
+
+ def test_missing_outputs_error(self):
+ empty = Path(tempfile.mkdtemp())
+ try:
+ with self.assertRaisesRegex(serve.MissingBuild, "mapgen.py build"):
+ serve.make_server(empty, 2, 0)
+ finally:
+ shutil.rmtree(empty)
+
+
+class CellTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.cells = np.load(self.root / "out" / "r2" / "cells.npz")
+
+ def test_cell_centre_and_inner_radius(self):
+ import h3.api.basic_int as h3
+ with Running(serve.make_server(self.root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")) as base:
+ c = json.loads(request(f"{base}/api/cell?lat=12.3&lon=45.6")[1])
+ lat, lon = h3.cell_to_latlng(h3.str_to_int(c["cell"]))
+ self.assertAlmostEqual(c["center"][0], lat, places=6)
+ self.assertAlmostEqual(c["center"][1], lon, places=6)
+ self.assertGreater(c["inner_km"], 0)
+
+ def test_reuse_circle_stays_inside_its_own_cell(self): # the hover shortcut must never show a neighbour's data
+ import h3.api.basic_int as h3
+ server = serve.make_server(self.root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")
+ server.server_close()
+ R, bad = 12742.0, []
+ for i in range(0, len(self.cells["g_ids"]), 7):
+ cell = int(self.cells["g_ids"][i])
+ clat, clon = h3.cell_to_latlng(cell)
+ d = server.world.describe(clat, clon)
+ la1, lo1, ang = np.radians(clat), np.radians(clon), d["inner_km"] / R
+ for az in np.radians(np.arange(0, 360, 15)): # 24 points on the circle
+ la2 = np.arcsin(np.sin(la1) * np.cos(ang) + np.cos(la1) * np.sin(ang) * np.cos(az))
+ lo2 = lo1 + np.arctan2(np.sin(az) * np.sin(ang) * np.cos(la1), np.cos(ang) - np.sin(la1) * np.sin(la2))
+ if h3.latlng_to_cell(np.degrees(la2), np.degrees(lo2), 2) != cell:
+ bad.append(h3.int_to_str(cell))
+ break
+ self.assertEqual(bad, [])
+
+ def test_known_cell(self):
+ server = serve.make_server(self.root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "pins.json")
+ i = 1234
+ lat, lon = float(self.cells["g_lat"][i]), float(self.cells["g_lon"][i])
+ with Running(server) as base:
+ status, body = request(f"{base}/api/cell?lat={lat}&lon={lon}")
+ bad, _ = request(f"{base}/api/cell?lat=95&lon=0")
+ badlon = json.loads(request(f"{base}/api/cell?lat=10&lon=inf")[1])["error"]
+ junk, _ = request(f"{base}/api/cell?lat=x")
+ self.assertEqual((status, bad, junk), (200, 400, 400))
+ self.assertIn("lon", badlon)
+ cell = json.loads(body)
+ import h3.api.basic_int as h3
+ self.assertEqual(cell["cell"], h3.int_to_str(int(self.cells["g_ids"][i])))
+ f = {x["key"]: x for x in cell["fields"]}
+ self.assertAlmostEqual(f["z_surface_m"]["value"], float(self.cells["z_surface_m"][i]), delta=0.5)
+ self.assertEqual(f["z_surface_m"]["alt"], f"{float(self.cells['z_surface_m'][i]) / 0.331013:,.0f} spans")
+ meta = json.loads((self.root / "out" / "r2" / "cells_meta.json").read_text())
+ self.assertEqual(f["holdridge"]["text"], meta["legends"]["holdridge"][int(self.cells["holdridge"][i])])
+ self.assertEqual(f["plate"]["text"], meta["legends"]["plates"][int(self.cells["plate"][i])])
+ self.assertIn(f["lake"]["text"], ("yes", "no"))
+ self.assertIn("T_summer", f)
+ g = float(self.cells["gravity_g"][i]) # 1.05 g = 180 (500₆) spans/moment²; low-g zones differ
+ self.assertEqual(f["gravity_g"]["alt"], f"{g * 9.80665 / 0.331013 * 2.403473 ** 2:,.0f} spans/moment²")
+
+
+class PinsTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.dir = Path(tempfile.mkdtemp())
+ self.path = self.dir / "places" / "pins.json"
+
+ def tearDown(self):
+ shutil.rmtree(self.dir)
+
+ def server(self):
+ return serve.make_server(self.root, 2, 0, pins_path=self.path)
+
+ def test_crud(self):
+ with Running(self.server()) as base:
+ s, body = request(base + "/api/pins", "POST", {"name": "Cell Am Zee", "lat": 12.5, "lon": 190.0,
+ "lore": "cell-am-zee", "note": "the University"})
+ pin = json.loads(body)
+ self.assertEqual(s, 201)
+ self.assertRegex(pin["id"], r"^pin-[0-9a-f]{8}$")
+ self.assertAlmostEqual(pin["lon"], -170.0)
+ s, body = request(f"{base}/api/pins/{pin['id']}", "PUT", {"name": "The University"})
+ self.assertEqual((s, json.loads(body)["name"], json.loads(body)["lore"]), (200, "The University", "cell-am-zee"))
+ s, body = request(base + "/api/pins")
+ self.assertEqual([p["name"] for p in json.loads(body)["pins"]], ["The University"])
+ self.assertEqual(request(f"{base}/api/pins/{pin['id']}", "DELETE")[0], 204)
+ self.assertEqual(request(f"{base}/api/pins/{pin['id']}", "DELETE")[0], 404)
+ self.assertEqual(json.loads(self.path.read_text()), {"pins": []})
+
+ def test_validation(self):
+ with Running(self.server()) as base:
+ for bad in ({"name": "", "lat": 0, "lon": 0}, {"name": "x", "lat": 100, "lon": 0},
+ {"name": "x", "lat": 0, "lon": 0, "lore": "Bad Id"},
+ {"name": "x" * 121, "lat": 0, "lon": 0}, {"name": "x", "lat": 0, "lon": 0, "note": "n" * 2001}):
+ self.assertEqual(request(base + "/api/pins", "POST", bad)[0], 400, bad)
+
+ def test_pin_file_format_and_cell_res(self):
+ import h3.api.basic_int as h3
+ with Running(self.server()) as base:
+ pin = json.loads(request(base + "/api/pins", "POST", {"name": "x", "lat": 1, "lon": 2})[1])
+ self.assertEqual(h3.get_resolution(h3.str_to_int(pin["cell"])), 5) # res_final, not the dev server's res 2
+ self.assertTrue(self.path.read_text().endswith("\n"))
+ self.assertEqual(self.path.stat().st_mode & 0o777, 0o644)
+
+ def test_epoch_and_move(self):
+ with Running(self.server()) as base:
+ pin = json.loads(request(base + "/api/pins", "POST", {"name": "x", "lat": 1, "lon": 2, "epoch": "epoch-high-fantasy"})[1])
+ bad = request(base + "/api/pins", "POST", {"name": "x", "lat": 1, "lon": 2, "epoch": "High Fantasy"})[0]
+ moved = json.loads(request(f"{base}/api/pins/{pin['id']}", "PUT", {"lat": -15.5, "lon": -34.2})[1])
+ cleared = json.loads(request(f"{base}/api/pins/{pin['id']}", "PUT", {"epoch": ""})[1])
+ self.assertEqual(pin["epoch"], "epoch-high-fantasy")
+ self.assertEqual(bad, 400)
+ self.assertEqual((moved["lat"], moved["lon"], moved["epoch"]), (-15.5, -34.2, "epoch-high-fantasy"))
+ self.assertNotEqual(moved["cell"], pin["cell"])
+ self.assertIsNone(cleared["epoch"])
+
+ def test_validation_types(self):
+ with Running(self.server()) as base:
+ for bad in ({"name": None, "lat": 0, "lon": 0}, {"name": "x", "lat": 0, "lon": 0, "lore": 12},
+ {"name": "x", "lat": 0, "lon": 0, "lore": "abc\n"}, {"name": "x", "lat": 0, "lon": 0, "note": 5}):
+ self.assertEqual(request(base + "/api/pins", "POST", bad)[0], 400, bad)
+ pid = json.loads(request(base + "/api/pins", "POST", {"name": "ok", "lat": 0, "lon": 0})[1])["id"]
+ for body in ([], b"", b"null"):
+ self.assertEqual(request(f"{base}/api/pins/{pid}", "PUT", body)[0], 400, body)
+
+ def test_cross_site_and_rebinding_blocked(self):
+ with Running(self.server()) as base:
+ port = int(base.rsplit(":", 1)[1])
+ c = http.client.HTTPConnection("127.0.0.1", port) # "simple" CORS request: no preflight
+ c.request("POST", "/api/pins", body=json.dumps({"name": "evil", "lat": 1, "lon": 1}),
+ headers={"Content-Type": "text/plain", "Origin": "http://evil.example"})
+ self.assertEqual(c.getresponse().status, 415)
+ for path in ("/api/pins", "/"): # DNS rebinding: foreign Host header
+ c = http.client.HTTPConnection("127.0.0.1", port)
+ c.request("GET", path, headers={"Host": "evil.example"})
+ self.assertEqual(c.getresponse().status, 403, path)
+ c = http.client.HTTPConnection("127.0.0.1", port)
+ c.request("GET", "/api/pins", headers={"Host": f"localhost:{port}"})
+ self.assertEqual(c.getresponse().status, 200)
+ self.assertFalse(self.path.exists())
+
+ def test_cross_site_reads_refused(self):
+ with Running(self.server()) as base:
+ port = int(base.rsplit(":", 1)[1])
+ def get(path, site):
+ c = http.client.HTTPConnection("127.0.0.1", port)
+ c.request("GET", path, headers={"Sec-Fetch-Site": site})
+ return c.getresponse().status
+ self.assertEqual(get("/api/meta", "cross-site"), 403)
+ self.assertEqual(get("/tiles/v1-0/relief/5/1/1.jpg", "cross-site"), 403)
+ self.assertEqual(get("/api/meta", "same-origin"), 200)
+ self.assertEqual(get("/", "cross-site"), 200) # following a shared link still opens the map
+
+ def test_negative_length_and_nul_path(self):
+ with Running(self.server()) as base:
+ port = int(base.rsplit(":", 1)[1])
+ with socket.create_connection(("127.0.0.1", port), timeout=5) as sk:
+ sk.sendall(f"POST /api/pins HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\n"
+ "Content-Length: -1\r\n\r\n".encode())
+ self.assertIn(b" 400 ", sk.recv(200))
+ self.assertEqual(request(base + "/js/%00x.js")[0], 404)
+ self.assertEqual(request(base + "/api/meta")[0], 200)
+
+ def test_bad_json_and_oversize(self):
+ with Running(self.server()) as base:
+ self.assertEqual(request(base + "/api/pins", "POST", b"{not json")[0], 400)
+ self.assertEqual(request(base + "/api/pins", "POST", b"x" * (serve.MAX_BODY + 1))[0], 400)
+ self.assertEqual(request(base + "/api/meta")[0], 200) # still alive
+
+ def test_crash_mid_write_keeps_file(self):
+ with Running(self.server()) as base:
+ request(base + "/api/pins", "POST", {"name": "keep", "lat": 1, "lon": 2})
+ before = self.path.read_text()
+ with mock.patch("serve.os.replace", side_effect=OSError("disk full")):
+ s, _ = request(base + "/api/pins", "POST", {"name": "lost", "lat": 3, "lon": 4})
+ self.assertEqual(s, 500)
+ self.assertEqual(self.path.read_text(), before)
+ self.assertEqual([p.name for p in self.path.parent.iterdir()], ["pins.json"]) # no temp files left
+
+ def test_concurrent_creates(self):
+ with Running(self.server()) as base:
+ threads = [threading.Thread(target=request, args=(base + "/api/pins", "POST",
+ {"name": f"p{k}", "lat": k, "lon": k}))
+ for k in range(20)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ self.assertEqual(len(json.loads(self.path.read_text())["pins"]), 20)
+
+class ProfileTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.cells = np.load(self.root / "out" / "r2" / "cells.npz")
+
+ def server(self):
+ return serve.make_server(self.root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "pins.json")
+
+ def test_profile_samples(self):
+ with Running(self.server()) as base:
+ s, body = request(f"{base}/api/profile?path=0,0;0,10;5,10&n=64")
+ p = json.loads(body)
+ mid = p["samples"][20]
+ _, cbody = request(f"{base}/api/cell?lat={mid['lat']}&lon={mid['lon']}")
+ self.assertEqual(s, 200)
+ S = p["samples"]
+ self.assertEqual(len(S), 64)
+ d = [x["d_km"] for x in S]
+ self.assertTrue(all(b >= a for a, b in zip(d, d[1:])))
+ length = 12742.0 * (np.radians(10) + np.radians(5))
+ self.assertAlmostEqual(p["stats"]["length_km"], length, delta=0.05)
+ self.assertAlmostEqual(d[-1], length, delta=0.05)
+ self.assertAlmostEqual(S[0]["lat"], 0.0, places=4)
+ self.assertAlmostEqual(S[-1]["lat"], 5.0, places=4)
+ self.assertAlmostEqual(S[-1]["lon"], 10.0, places=4)
+ cell = {f["key"]: f for f in json.loads(cbody)["fields"]}
+ self.assertAlmostEqual(mid["z_cell_m"], cell["z_surface_m"]["value"], delta=0.5)
+ self.assertIsInstance(mid["water"], bool) # procedural coast: tests/test_tiles.py ProfileSurfaceTest
+ z = [x["z_m"] for x in S]
+ st = p["stats"]
+ self.assertAlmostEqual(st["ascent_m"] - st["descent_m"], z[-1] - z[0], delta=1.0)
+ self.assertAlmostEqual(st["min_m"], min(z), delta=0.1)
+ self.assertAlmostEqual(st["max_m"], max(z), delta=0.1)
+ self.assertTrue(0.0 <= st["water_km"] <= st["length_km"] + 1e-6)
+
+ def test_profile_limits(self):
+ with Running(self.server()) as base:
+ bad = ["path=0,0&n=64", "path=" + ";".join(f"{k * 0.1},0" for k in range(101)), "path=0,0;1,1&n=8",
+ "path=0,0;1,1&n=2000", "path=95,0;1,1", "path=a,b;c", "path=0,0;0,180", "n=64", "path=nan,0;1,1",
+ "path=0,inf;1,1"]
+ codes = {q: request(f"{base}/api/profile?{q}") for q in bad}
+ same = request(f"{base}/api/profile?path=10,10;10,10&n=16")
+ for q, (s, body) in codes.items():
+ self.assertEqual(s, 400, q)
+ self.assertTrue(json.loads(body)["error"], q)
+ self.assertIn("antipodal", json.loads(codes["path=0,0;0,180"][1])["error"])
+ self.assertIn("lon", json.loads(codes["path=0,inf;1,1"][1])["error"])
+ self.assertIn("lat", json.loads(codes["path=95,0;1,1"][1])["error"])
+ self.assertEqual(same[0], 200)
+ self.assertEqual(json.loads(same[1])["stats"]["length_km"], 0.0)
+
+
+class LocateTest(unittest.TestCase):
+ def test_locate(self):
+ import h3.api.basic_int as h3
+ cell = h3.int_to_str(h3.latlng_to_cell(12.5, 40.0, 5))
+ with Running(serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "pins.json")) as base:
+ s, body = request(f"{base}/api/locate?cell={cell.upper()}")
+ bad = [request(f"{base}/api/locate?cell={q}")[0] for q in ("zz", "", "0528308ffffffff")]
+ r = json.loads(body)
+ self.assertEqual(s, 200)
+ self.assertEqual((r["cell"], r["res"]), (cell, 5))
+ self.assertLess(abs(r["lat"] - 12.5) + abs(r["lon"] - 40.0), 0.5)
+ self.assertEqual(bad, [400, 400, 400])
+
+
+class RegionsApiTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp())
+ self.server = serve.make_server(built_world(), 2, 0, pins_path=self.tmp / "p.json",
+ regions_path=self.tmp / "regions.json", regions_dir=self.tmp / "results")
+
+ def test_read_only_regions_refuse_edits_and_start_no_build(self):
+ server = serve.make_server(built_world(), 2, 0, pins_path=self.tmp / "p2.json",
+ regions_path=self.tmp / "ro.json", regions_dir=self.tmp / "ro", regions_read_only=True)
+ ok = {"name": "Test area", "outline": [[10, 20], [10, 24], [14, 24], [14, 20]]}
+ with Running(server) as base:
+ s, _ = request(base + "/api/regions", "POST", ok)
+ d = request(base + "/api/regions/region-00000000", "DELETE")[0]
+ listed = json.loads(request(base + "/api/regions")[1])
+ self.assertEqual((s, d), (403, 403))
+ self.assertEqual(listed["regions"], [])
+ self.assertFalse((self.tmp / "ro.json").exists())
+
+ def test_create_list_delete(self):
+ ok = {"name": "Test area", "outline": [[10, 20], [10, 24], [14, 24], [14, 20]]}
+ with Running(self.server) as base:
+ s, body = request(base + "/api/regions", "POST", ok)
+ self.assertEqual(s, 201)
+ r = json.loads(body)
+ self.assertRegex(r["id"], r"^region-[0-9a-f]{8}$")
+ self.assertGreater(r["area_km2"], 100000)
+ listed = json.loads(request(base + "/api/regions")[1])
+ self.assertEqual([x["id"] for x in listed["regions"]], [r["id"]])
+ self.assertIn("url", listed["tiles"])
+ self.assertEqual(request(base + f"/api/regions/{r['id']}", "DELETE")[0], 204)
+ self.assertEqual(request(base + f"/api/regions/{r['id']}", "DELETE")[0], 404)
+ self.assertEqual(json.loads((self.tmp / "regions.json").read_text())["regions"], [])
+
+ def test_rejects_bad_outlines(self):
+ bad = [{"name": "", "outline": [[0, 0], [0, 1], [1, 1]]}, {"name": "x", "outline": [[0, 0], [0, 1]]},
+ {"name": "x", "outline": [[0, 0], [2, 2], [0, 2], [2, 0]]}, # a bow tie crosses itself
+ {"name": "x", "outline": [[0, 0], [0, "a"], [1, 1]]}, {"name": "x", "outline": [[95, 0], [0, 1], [1, 1]]},
+ "not an object"]
+ with Running(self.server) as base:
+ codes = [request(base + "/api/regions", "POST", b)[0] for b in bad]
+ self.assertEqual(codes, [400] * len(bad))
+
+ def test_writes_need_json(self):
+ with Running(self.server) as base:
+ req = urllib.request.Request(base + "/api/regions", data=b"name=x", method="POST",
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ try:
+ s = urllib.request.urlopen(req).status
+ except urllib.error.HTTPError as e:
+ s = e.code
+ e.close()
+ self.assertEqual(s, 415)
+
+ def test_regions_live_where_the_server_was_told(self):
+ self.assertEqual(self.server.tiles.regions_dir, self.tmp / "results")
+
+
+def wait_status(base, want, timeout=120):
+ t, st = time.time(), None
+ while time.time() - t < timeout:
+ st = json.loads(request(base + "/api/regions")[1])
+ if st["status"]["state"] in want:
+ return st
+ time.sleep(0.2)
+ raise AssertionError(f"status never reached {want}: {st and st['status']}")
+
+
+class RegionBuildTest(unittest.TestCase):
+ def _server(self, tmp):
+ return serve.make_server(built_world(), 2, 0, pins_path=tmp / "p.json", regions_path=tmp / "regions.json",
+ regions_dir=tmp / "results")
+
+ def _outline(self):
+ a = np.load(built_world() / "out" / "r2" / "cells.npz")
+ i = big_river(a)
+ la, lo = float(a["g_lat"][i]), float(a["g_lon"][i])
+ return [[la - 3, lo - 3], [la - 3, lo + 3], [la + 3, lo + 3], [la + 3, lo - 3]]
+
+ def test_save_builds_and_tiles_switch(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ with Running(server) as base:
+ url0 = json.loads(request(base + "/api/regions")[1])["tiles"]["url"]
+ request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})
+ st = wait_status(base, {"ready", "failed"})
+ self.assertEqual(st["status"]["state"], "ready", st["status"])
+ self.assertNotEqual(st["tiles"]["url"], url0)
+ self.assertFalse(server.tiles.regions.empty)
+ lat, lon = self._outline()[0][0] + 3, self._outline()[0][1] + 3
+ self.assertTrue(json.loads(request(base + f"/api/cell?lat={lat}&lon={lon}")[1])["refined"])
+ self.assertEqual(request(base + url0.format(layer="relief", z=5, x=0, y=0))[0], 404, "old tile URLs stop")
+
+ def test_delete_rebuilds_and_drops_the_area(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ with Running(server) as base:
+ r = json.loads(request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})[1])
+ wait_status(base, {"ready"})
+ request(base + f"/api/regions/{r['id']}", "DELETE")
+ wait_status(base, {"ready"})
+ self.assertTrue(server.tiles.regions.empty)
+
+ def test_saves_coalesce_and_failures_report(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ with mock.patch("refine.refine_area", side_effect=RuntimeError("boom")), Running(server) as base:
+ for k in range(3):
+ request(base + "/api/regions", "POST", {"name": f"R{k}", "outline": self._outline()})
+ st = wait_status(base, {"failed"})
+ self.assertIn("boom", st["status"]["error"])
+ self.assertTrue(server.tiles.regions.empty, "the map stays as it was")
+
+ def test_progress_is_reported(self):
+ seen = []
+ tmp = Path(tempfile.mkdtemp())
+ (tmp / "regions.json").write_text(json.dumps({"regions": [{"id": "region-00000002", "name": "A",
+ "outline": self._outline(), "created": "2026-09-25"}]}))
+ import refine as RF
+ RF.build_areas(built_world(), 2, tmp / "regions.json", log=lambda s: None, regions_root=tmp / "results",
+ progress=lambda stage, f: seen.append((stage, f)))
+ self.assertTrue({"fields", "erosion", "rivers", "ground", "saving"} <= {s for s, _ in seen})
+ fr = [f for _, f in seen]
+ self.assertEqual(fr, sorted(fr), "fractions only grow")
+ self.assertTrue(0.0 <= fr[0] and fr[-1] <= 1.0)
+
+ def test_after_a_build_the_region_tiles_are_prerendered(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ server.prerender_z = 5
+ with Running(server) as base:
+ request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})
+ wait_status(base, {"ready"})
+ t, st = time.time(), {}
+ while time.time() - t < 120:
+ st = json.loads(request(base + "/api/regions")[1])["status"]
+ if st.get("tiles") and st["tiles"]["done"] == st["tiles"]["total"] > 0:
+ break
+ time.sleep(0.3)
+ self.assertEqual(st["tiles"]["done"], st["tiles"]["total"], st)
+ self.assertTrue(any(server.tiles.cache_dir.rglob("*.bin")))
+
+ def test_rejects_regions_far_over_the_size_limit(self):
+ tmp = Path(tempfile.mkdtemp())
+ with Running(self._server(tmp)) as base:
+ s, body = request(base + "/api/regions", "POST", {"name": "Huge", "outline": [[-30, 0], [-30, 50], [30, 50], [30, 0]]})
+ self.assertEqual(s, 400)
+ self.assertIn("too large", json.loads(body)["error"])
+
+ def test_ready_is_never_reported_while_a_save_waits(self):
+ import refine as RF
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ seen, real = [], RF.build_areas
+
+ def build(*a, **k):
+ if not seen:
+ server.jobs.request() # a save lands during the build
+ seen.append("build")
+ return real(*a, **k)
+ server.jobs.prerender = lambda: seen.append(server.jobs.status()["state"])
+ with mock.patch("refine.build_areas", side_effect=build), Running(server) as base:
+ request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})
+ t = time.time()
+ while seen.count("build") < 2 or seen[-1] == "build":
+ self.assertLess(time.time() - t, 60, seen)
+ time.sleep(0.1)
+ self.assertEqual(seen, ["build", "build", "ready"], "ready (and pre-rendering) only once nothing waits")
+
+ def test_a_crashing_build_never_wedges_the_queue(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ with mock.patch("refine.build_areas", side_effect=[serve.MissingBuild("world rebuilding"), []]), \
+ Running(server) as base:
+ server.jobs.request()
+ self.assertEqual(wait_status(base, {"failed"}, 20)["status"]["state"], "failed")
+ server.jobs.request()
+ self.assertEqual(wait_status(base, {"ready"}, 20)["status"]["state"], "ready")
+
+ def test_a_malformed_regions_file_is_reported_not_fatal(self):
+ tmp = Path(tempfile.mkdtemp())
+ (tmp / "regions.json").write_text('{"regions": [ {"name": broken')
+ server = self._server(tmp) # starts anyway
+ with Running(server) as base:
+ st = json.loads(request(base + "/api/regions")[1])
+ self.assertEqual(st["regions"], [])
+ self.assertEqual(st["status"]["state"], "failed")
+ self.assertIn("regions.json", st["status"]["error"])
+ s, _ = request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})
+ self.assertGreaterEqual(s, 400)
+ self.assertEqual((tmp / "regions.json").read_text(), '{"regions": [ {"name": broken', "never overwritten")
+
+ def test_each_region_says_whether_it_is_refined(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ o = self._outline()
+ far = [[la + 40 if la < 40 else la - 40, lo + 60] for la, lo in o]
+ with Running(server) as base:
+ request(base + "/api/regions", "POST", {"name": "A", "outline": o})
+ wait_status(base, {"ready"})
+ with mock.patch("refine.refine_area", side_effect=RuntimeError("boom")):
+ request(base + "/api/regions", "POST", {"name": "B", "outline": far})
+ st = wait_status(base, {"failed"})
+ self.assertEqual({r["name"]: r["refined"] for r in st["regions"]}, {"A": True, "B": False})
+
+ def test_saves_during_a_build_coalesce_into_one_more_build(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ builds = []
+
+ def slow(*a, **k):
+ builds.append(1)
+ time.sleep(1.0)
+ return []
+ with mock.patch("refine.build_areas", side_effect=slow), Running(server) as base:
+ for k in range(4):
+ request(base + "/api/regions", "POST", {"name": f"R{k}", "outline": self._outline()})
+ wait_status(base, {"ready"})
+ time.sleep(0.3)
+ self.assertEqual(len(builds), 2, "the first save builds; the three during it share one more build")
+ self.assertEqual(len(json.loads(request(base + "/api/regions")[1])["regions"]), 4, "no save lost")
+
+ def test_deleting_a_region_while_it_builds_leaves_nothing_behind(self):
+ import refine as RF
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ with Running(server) as base:
+ r = json.loads(request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})[1])
+ wait_status(base, {"building"}, 30)
+ self.assertEqual(request(base + f"/api/regions/{r['id']}", "DELETE")[0], 204)
+ t = time.time()
+ while True:
+ st = json.loads(request(base + "/api/regions")[1])["status"]
+ if st["state"] == "ready" and not server.jobs.pending:
+ break
+ self.assertLess(time.time() - t, 120, st)
+ time.sleep(0.2)
+ self.assertTrue(server.tiles.regions.empty, "the map shows no refined area")
+ self.assertEqual(RF.built_areas(tmp / "results", server.tiles.fingerprint), [], "no result left on disk")
+
+ def test_a_restart_with_everything_built_does_no_heavy_area_work(self):
+ tmp = Path(tempfile.mkdtemp())
+ server = self._server(tmp)
+ with Running(server) as base:
+ request(base + "/api/regions", "POST", {"name": "A", "outline": self._outline()})
+ wait_status(base, {"ready"})
+ with mock.patch("refine.area_cells", side_effect=AssertionError("area_cells at start")):
+ again = self._server(tmp) # a restart: nothing to build …
+ with Running(again) as base:
+ st = json.loads(request(base + "/api/regions")[1])
+ self.assertEqual(st["status"]["state"], "idle")
+ self.assertEqual([r["refined"] for r in st["regions"]], [True], "… and answered from the build's record")
+
+ def test_restart_queues_missing_areas(self):
+ tmp = Path(tempfile.mkdtemp())
+ (tmp / "regions.json").write_text(json.dumps({"regions": [{"id": "region-00000001", "name": "A",
+ "outline": self._outline(), "created": "2026-09-25"}]}))
+ server = self._server(tmp)
+ with Running(server) as base:
+ wait_status(base, {"ready"})
+ self.assertFalse(server.tiles.regions.empty)
+
+
+class ExportsApiTest(unittest.TestCase):
+ def _server(self, tmp, workers=0):
+ return serve.make_server(built_world(), 2, 0, pins_path=tmp / "p.json", regions_path=tmp / "regions.json",
+ regions_dir=tmp / "results", exports_dir=tmp / "exports", workers=workers)
+
+ def _wait(self, base, want, timeout=120):
+ t, st = time.time(), None
+ while time.time() - t < timeout:
+ st = json.loads(request(base + "/api/exports")[1])
+ if st["status"]["state"] in want:
+ return st
+ time.sleep(0.2)
+ raise AssertionError(f"export status never reached {want}: {st}")
+
+ def test_export_from_the_viewer(self):
+ tmp = Path(tempfile.mkdtemp())
+ with Running(self._server(tmp)) as base:
+ s, body = request(base + "/api/exports", "POST", {"lat": 10.0, "lon": 20.0, "size_km": 100, "res_m": 2000,
+ "name": "test-area"})
+ self.assertEqual(s, 202, body)
+ st = self._wait(base, {"done", "failed"})
+ self.assertEqual(st["status"]["state"], "done", st["status"])
+ (e,) = st["exports"]
+ self.assertEqual((e["name"], e["samples"], e["res_m"]), ("test-area", 65, 2000))
+ self.assertTrue((tmp / "exports" / "test-area" / "height.f32").exists())
+ self.assertEqual(request(base + "/api/exports", "POST", {"lat": 10, "lon": 20, "size_km": 100, "res_m": 2000,
+ "name": "test-area"})[0], 409, "never overwritten")
+
+ def test_rejects_bad_exports(self):
+ tmp = Path(tempfile.mkdtemp())
+ bad = [{"lat": 95, "lon": 0, "size_km": 10, "res_m": 10}, {"lat": 0, "lon": 0, "size_km": 0, "res_m": 10},
+ {"lat": 0, "lon": 0, "size_km": 10, "res_m": 0.1}, {"lat": 0, "lon": 0, "size_km": 400, "res_m": 10},
+ {"lat": 0, "lon": 0, "size_km": 10, "res_m": 10, "name": "../escape"},
+ {"lat": 0, "lon": 0, "size_km": 100, "res_m": 1}, "nope",
+ {"lat": 84.9, "lon": 0, "size_km": 40, "res_m": 100}, # reaches within 5° of the pole
+ {"lat": 75, "lon": 0, "size_km": 80, "res_m": 10}] # ≈ 5,000 tiles: far too much memory
+ with Running(self._server(tmp)) as base:
+ codes = [request(base + "/api/exports", "POST", b)[0] for b in bad]
+ self.assertEqual(codes, [400] * len(bad))
+ self.assertFalse((tmp / "exports").exists() and any((tmp / "exports").iterdir()))
+
+ def test_half_written_exports_are_neither_listed_nor_kept(self):
+ tmp = Path(tempfile.mkdtemp())
+ junk = tmp / "exports" / ".island-x1y2z3" # an export interrupted by Ctrl-C
+ junk.mkdir(parents=True)
+ (junk / "meta.json").write_text('{"name": "island"}')
+ server = self._server(tmp) # a restart clears it
+ with Running(server) as base:
+ self.assertEqual(json.loads(request(base + "/api/exports")[1])["exports"], [])
+ self.assertFalse(junk.exists())
+
+ def test_one_export_at_a_time(self):
+ import export as EX
+ tmp = Path(tempfile.mkdtemp())
+ real = EX.export
+
+ def slow(*a, **k):
+ time.sleep(1.0)
+ return real(*a, **k)
+ with mock.patch("export.export", side_effect=slow), Running(self._server(tmp)) as base:
+ ok = {"lat": 10, "lon": 20, "size_km": 50, "res_m": 2000}
+ self.assertEqual(request(base + "/api/exports", "POST", {**ok, "name": "a"})[0], 202)
+ self.assertEqual(request(base + "/api/exports", "POST", {**ok, "name": "b"})[0], 409, "busy")
+ self._wait(base, {"done"})
+
+ def test_render_workers_make_the_same_export(self):
+ tmp = Path(tempfile.mkdtemp())
+ with Running(self._server(tmp, workers=2)) as base:
+ request(base + "/api/exports", "POST", {"lat": 10.0, "lon": 20.0, "size_km": 100, "res_m": 2000, "name": "w"})
+ self._wait(base, {"done"})
+ tmp2 = Path(tempfile.mkdtemp())
+ with Running(self._server(tmp2)) as base:
+ request(base + "/api/exports", "POST", {"lat": 10.0, "lon": 20.0, "size_km": 100, "res_m": 2000, "name": "w"})
+ self._wait(base, {"done"})
+ for f in ("height.f32", "water.png", "preview.png"):
+ self.assertEqual((tmp / "exports" / "w" / f).read_bytes(), (tmp2 / "exports" / "w" / f).read_bytes(), f)
+
+
+class ServeCacheWorldTest(unittest.TestCase):
+ def test_world_arrays_come_from_the_cache_and_equal_the_npz(self):
+ root = built_world()
+ w1 = serve.World(root, 2)
+ w2 = serve.World(root, 2)
+ self.assertTrue((w1.cache / "done").exists())
+ self.assertEqual(w1.cache, w2.cache)
+ with np.load(root / "out" / "r2" / "cells.npz") as z:
+ for k in serve.LOAD_KEYS:
+ if k not in z.files:
+ continue
+ want = z[k].astype(np.float32) if z[k].dtype == np.float64 else z[k]
+ self.assertIsInstance(w2.arrays[k], np.memmap, k)
+ self.assertEqual(w2.arrays[k].dtype, want.dtype, k)
+ np.testing.assert_array_equal(w2.arrays[k], want, err_msg=k)
+
+ def test_world_without_cache_dir_write(self):
+ root = built_world()
+ with mock.patch.object(serve.servecache, "write", side_effect=OSError("read-only file system")):
+ w = serve.World(root, 2, use_cache=True)
+ plain = serve.World(root, 2, use_cache=False)
+ for k in plain.arrays:
+ np.testing.assert_array_equal(w.arrays[k], plain.arrays[k], err_msg=k)
+
+ def test_low_memory_world_cache_equals_plain_and_cleans_up(self):
+ src = built_world()
+ plain = serve.World(src, 2, use_cache=False) # oracle: arrays read straight into RAM
+ with tempfile.TemporaryDirectory() as d, mock.patch.dict(os.environ, {"WORLDGEN_LOW_MEMORY": "1"}):
+ root = Path(d) / "w"
+ shutil.copytree(src, root, symlinks=True)
+ for p in (root / "out" / "r2").glob("serve-*"):
+ shutil.rmtree(p)
+ with mock.patch.object(serve.servecache, "spiller", wraps=serve.servecache.spiller) as sp:
+ w = serve.World(root, 2)
+ self.assertTrue(any(c.args[0] is not None for c in sp.call_args_list), "fields spilled to disk")
+ for k in plain.arrays:
+ np.testing.assert_array_equal(w.arrays[k], plain.arrays[k], err_msg=k)
+ self.assertEqual(list((root / "out" / "r2").glob(".spill-*")), [])
+
+ def test_cache_key_follows_the_build(self):
+ root = built_world()
+ out = root / "out" / "r2"
+ k1 = serve.world_cache_key(out)
+ st = (out / "cells.npz").stat()
+ try:
+ os.utime(out / "cells.npz")
+ self.assertNotEqual(serve.world_cache_key(out), k1)
+ finally: # the shared test world: later tests keep their fingerprints
+ os.utime(out / "cells.npz", ns=(st.st_atime_ns, st.st_mtime_ns))
+
+ def test_cached_arrays_are_read_only_and_everything_still_works(self):
+ root = built_world()
+ w = serve.World(root, 2)
+ self.assertFalse(w.arrays["z_surface_m"].flags.writeable)
+ i = len(w.ids) // 3
+ lat, lon = float(np.degrees(np.arcsin(w.arrays["g_xyz"][i][2]))), float(np.degrees(np.arctan2(w.arrays["g_xyz"][i][1], w.arrays["g_xyz"][i][0])))
+ d = w.describe(lat, lon) # the inspector path on memory maps
+ self.assertIn("fields", d)
+
+
+class InspectorFieldsTest(unittest.TestCase):
+ def test_sea_floor_and_zone_fields(self):
+ import h3.api.basic_int as h3
+ w = serve.World(built_world(), 2, era={"name": "x", "label": "Era X"})
+ i = int(np.flatnonzero(w.arrays["ocean"].astype(bool))[0])
+ la, lo = h3.cell_to_latlng(int(w.ids[i]))
+ d = w.describe(la, lo)
+ keys = {f["key"]: f for f in d["fields"]}
+ for k in ("seabed_type", "seabed_mineral", "bottom_temp_c", "sediment_m", "vent_potential",
+ "o2_fraction", "pressure_bar", "fire_reactivity", "po2_bar", "gravity_g"):
+ self.assertIn(k, keys, k)
+ self.assertEqual(keys["seabed_type"]["group"], "Sea floor")
+ self.assertEqual(d["era"], {"name": "x", "label": "Era X"})
+ j = int(np.flatnonzero(~w.arrays["ocean"].astype(bool))[0])
+ la, lo = h3.cell_to_latlng(int(w.ids[j]))
+ self.assertNotIn("seabed_type", {f["key"] for f in w.describe(la, lo)["fields"]}, "land: no sea floor")
+
+ def test_sea_rows(self):
+ # a sea cell lists SST, current speed and productivity in group "Sea"; a land cell lists none of them
+ import h3.api.basic_int as h3
+ w = serve.World(built_world(), 2)
+ i = int(np.flatnonzero(w.arrays["ocean"].astype(bool))[0])
+ keys = {f["key"]: f for f in w.describe(*h3.cell_to_latlng(int(w.ids[i])))["fields"]}
+ for k in ("sst", "current_speed", "productivity"):
+ self.assertIn(k, keys, k)
+ self.assertEqual(keys[k]["group"], "Sea")
+ j = int(np.flatnonzero(~w.arrays["ocean"].astype(bool))[0])
+ land = {f["key"] for f in w.describe(*h3.cell_to_latlng(int(w.ids[j])))["fields"]}
+ for k in ("sst", "current_speed", "productivity"):
+ self.assertNotIn(k, land, k)
+
+
+class EraServerTest(unittest.TestCase):
+ def test_every_era_is_served(self):
+ from tests.test_refine import era_world
+ root, far, cut, _ = era_world()
+ with Running(serve.make_server(root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json",
+ regions_dir=Path(tempfile.mkdtemp()))) as base:
+ m = json.loads(request(base + "/api/meta")[1])
+ self.assertEqual(m["default_era"], "after")
+ self.assertEqual(m["era"], "after")
+ self.assertEqual([e["name"] for e in m["eras"]], ["before", "after"])
+ b = json.loads(request(base + "/api/meta?era=before")[1])
+ self.assertNotEqual(b["tiles"]["url"], m["tiles"]["url"])
+ self.assertTrue(b["layers"][0]["file"].endswith("?era=before"))
+ for meta in (m, b):
+ tag = meta["tiles"]["url"].split("/")[2]
+ self.assertEqual(request(base + f"/tiles/{tag}/relief/5/10/10.jpg")[0], 200)
+ q = f"lat={cut[0]}&lon={cut[1]}"
+ ca = json.loads(request(base + f"/api/cell?{q}&era=after")[1])
+ cb = json.loads(request(base + f"/api/cell?{q}&era=before")[1])
+ self.assertEqual((ca["era"]["name"], cb["era"]["name"]), ("after", "before"))
+ za = {f["key"]: f["value"] for f in ca["fields"]}["z_surface_m"]
+ zb = {f["key"]: f["value"] for f in cb["fields"]}["z_surface_m"]
+ self.assertLess(za, zb, "the cut lowered the ground")
+ self.assertEqual(request(base + f"/api/cell?{q}&era=nope")[0], 400)
+ self.assertEqual(request(base + "/data/viewer/layers.json?era=before")[0], 200)
+
+ def test_single_world_server_has_one_era(self):
+ with Running(serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")) as base:
+ m = json.loads(request(base + "/api/meta")[1])
+ self.assertEqual(len(m["eras"]), 1)
+ self.assertEqual(m["era"], m["default_era"])
+ self.assertEqual(request(base + "/api/cell?lat=10&lon=20&era=nope")[0], 400)
+ self.assertEqual(request(base + "/api/cell?lat=10&lon=20")[0], 200)
+
+
+class VentsApiTest(unittest.TestCase):
+ def test_vents_without_refined_areas(self):
+ with Running(serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json",
+ regions_dir=Path(tempfile.mkdtemp()))) as base:
+ s, b = request(base + "/api/vents")
+ self.assertEqual(s, 200)
+ v = json.loads(b)
+ self.assertEqual(v["vents"], [])
+ self.assertEqual(v["types"][0], "black smoker")
+
+ def test_vents_of_refined_areas(self):
+ from unittest import mock
+ import refine as RF
+ import seafloor as SF
+ from tests.test_refine import square, write_regions
+ root = built_world()
+ w = RF.WorldCells(root / "out" / "r2")
+ i = int(np.argmax(np.where(w.a["ocean"].astype(bool), w.a["vent_potential"], -1.0))) # the likeliest vents
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(float(w.a["g_lat"][i]), float(w.a["g_lon"][i]), 3, "deep"))
+ with mock.patch.dict(SF.VENT, rate=1.0): # the small world's odds give ≈ 1 vent: make it many
+ RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ with Running(serve.make_server(root, 2, 0, pins_path=tmp / "p.json", regions_path=reg,
+ regions_dir=tmp / "res")) as base:
+ v = json.loads(request(base + "/api/vents")[1])
+ links = [p for p in (tmp / "res").glob(f"*-m{RF.MODEL}/*") if p.is_symlink()]
+ n = len({int(c) for p in links for c in np.load(p / "vents.npz")["cell"]})
+ self.assertGreater(n, 0, "a deep-sea square holds vents")
+ self.assertEqual(len(v["vents"]), n)
+ for x in v["vents"]:
+ self.assertEqual(set(x), {"lat", "lon", "type", "temp_c", "flow", "mineral"})
+
+ def test_plateau_outlines(self):
+ root = built_world()
+ from mapgen import config as C
+ with Running(serve.make_server(root, 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")) as base:
+ meta = json.loads(request(base + "/api/meta")[1])
+ s, b = request(base + "/api/plateaus.geojson")
+ fc = json.loads(b)
+ self.assertEqual(s, 200)
+ self.assertEqual(len(fc["features"]), len(C.load(root)[1].get("plateau", [])))
+ if fc["features"]:
+ self.assertIn("plateaus", [o["id"] for o in meta["overlays"]])
+
+
+class EraRegionsMissingTest(unittest.TestCase):
+ def test_a_non_default_era_without_its_areas_is_missing(self):
+ import shutil
+ import refine as RF
+ from tests.test_refine import era_world, square, write_regions
+ root, far, cut, _ = era_world()
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(*far, 2, "far"))
+ RF.build_areas(root, 2, reg, log=lambda s: None, regions_root=tmp / "res")
+ server = serve.make_server(root, 2, 0, pins_path=tmp / "p.json", regions_path=reg, regions_dir=tmp / "res")
+ try:
+ self.assertFalse(serve.regions_missing(server), "every era built")
+ base = server.sources["before"]
+ shutil.rmtree(RF.results_dir(tmp / "res", base.fingerprint))
+ self.assertTrue(serve.regions_missing(server), "the base era (not the default) lost its areas")
+ finally:
+ server.server_close()
+
+
+class ClientGoneTest(unittest.TestCase):
+ def test_a_reply_to_a_client_that_hung_up_is_dropped_quietly(self):
+ class Gone(io.RawIOBase):
+ def write(self, b):
+ raise BrokenPipeError(32, "Broken pipe")
+ h = serve.Handler.__new__(serve.Handler)
+ h.wfile, h.request_version, h.requestline, h.command = Gone(), "HTTP/1.1", "GET / HTTP/1.1", "GET"
+ h.client_address, h.close_connection = ("127.0.0.1", 0), False
+ h.log_request = lambda *a, **k: None
+ h._json(200, {"x": 1}) # no exception: the client is gone, nothing to answer
+ self.assertTrue(h.close_connection)
+
+
+class GzipTest(unittest.TestCase):
+ def _reply(self, accept, body, ctype):
+ out = io.BytesIO()
+ h = serve.Handler.__new__(serve.Handler)
+ h.wfile, h.request_version, h.requestline, h.command = out, "HTTP/1.1", "GET / HTTP/1.1", "GET"
+ h.client_address, h.close_connection = ("127.0.0.1", 0), False
+ h.log_request = lambda *a, **k: None
+ h.headers = {} if accept is None else {"Accept-Encoding": accept}
+ h._send(200, body, ctype)
+ head, _, got = out.getvalue().partition(b"\r\n\r\n")
+ return head.decode().lower(), got
+
+ def test_mesh_and_json_gzipped_when_accepted(self):
+ import gzip
+ body = np.arange(20000, dtype=np.float32).tobytes()
+ for ctype in ("application/octet-stream", "application/json"):
+ head, got = self._reply("gzip, deflate, br", body, ctype)
+ self.assertIn("content-encoding: gzip", head)
+ self.assertIn("vary: accept-encoding", head)
+ self.assertIn(f"content-length: {len(got)}", head)
+ self.assertLess(len(got), len(body))
+ self.assertEqual(gzip.decompress(got), body)
+
+ def test_plain_when_not_accepted_small_or_already_compressed(self):
+ body = bytes(5000)
+ for accept, b, ctype in ((None, body, "application/json"), ("identity", body, "application/json"),
+ ("gzip;q=0", body, "application/json"), ("gzip", body, "image/jpeg"),
+ ("gzip", b"{}", "application/json")):
+ head, got = self._reply(accept, b, ctype)
+ self.assertNotIn("content-encoding", head, (accept, ctype))
+ self.assertEqual(got, b)
+
+
+class PublicModeTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.dir = Path(tempfile.mkdtemp())
+ lore = self.dir / "lore"
+ lore.mkdir()
+ (lore / "true-gods.md").write_text("---\nid: true-gods\n---\nTier-1.\n")
+ self.lore = lore
+
+ def tearDown(self):
+ shutil.rmtree(self.dir)
+
+ def server(self, public):
+ s = serve.make_server(self.root, 2, 0, pins_path=self.dir / "pins.json", lore_dir=self.lore,
+ regions_path=self.dir / "regions.json", exports_dir=self.dir / "exports")
+ s.public = public
+ return s
+
+ def test_private_server_serves_edit_apis(self):
+ with Running(self.server(False)) as base:
+ meta = json.loads(request(base + "/api/meta")[1])
+ self.assertFalse(meta["read_only"])
+ self.assertEqual(meta["lore_ids"], ["true-gods"])
+ self.assertEqual(request(base + "/api/pins")[0], 200)
+ self.assertEqual(request(base + "/api/pins", "POST", {"name": "A", "lat": 1, "lon": 2})[0], 201)
+
+ def test_public_server_is_read_only_and_keeps_notes_private(self):
+ (self.dir / "pins.json").write_text(json.dumps({"pins": [{"id": "x", "name": "Secret", "lat": 1, "lon": 2}]}))
+ with Running(self.server(True)) as base:
+ meta = json.loads(request(base + "/api/meta")[1])
+ self.assertTrue(meta["read_only"])
+ self.assertEqual(meta["lore_ids"], [], "lore entry names stay private")
+ for path in ("/api/pins", "/api/regions", "/api/exports"):
+ self.assertEqual(request(base + path)[0], 404, path)
+ self.assertEqual(request(base + "/api/pins", "POST", {"name": "A", "lat": 1, "lon": 2})[0], 403)
+ self.assertEqual(request(base + "/api/pins/x", "PUT", {"name": "B", "lat": 1, "lon": 2})[0], 403)
+ self.assertEqual(request(base + "/api/pins/x", "DELETE")[0], 403)
+ self.assertEqual(request(base + "/api/regions", "POST", {"name": "R", "outline": [[0, 0], [0, 1], [1, 1]]})[0], 403)
+ self.assertEqual(request(base + "/api/exports", "POST", {})[0], 403)
+ self.assertEqual(request(base + "/api/cell?lat=10&lon=20")[0], 200, "the map itself still answers")
+ tile = meta["tiles"]["url"].replace("{layer}", "relief").replace("{z}", "5").replace("{x}", "0").replace("{y}", "0")
+ self.assertEqual(request(base + tile)[0], 200)
+ self.assertIn("Secret", (self.dir / "pins.json").read_text(), "nothing written")
+
+
+class AllowHostTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.dir = Path(tempfile.mkdtemp())
+
+ def tearDown(self):
+ shutil.rmtree(self.dir)
+
+ def get(self, base, path, host):
+ port = int(base.rsplit(":", 1)[1])
+ c = http.client.HTTPConnection("127.0.0.1", port)
+ try:
+ c.request("GET", path, headers={"Host": host})
+ r = c.getresponse()
+ return r.status, r.getheader("Cache-Control"), r.read()
+ finally:
+ c.close()
+
+ def test_public_name_accepted_only_when_allowed_and_tiles_cached_for_good(self):
+ s = serve.make_server(self.root, 2, 0, pins_path=self.dir / "pins.json")
+ s.public, s.allowed_hosts = True, {"maps.example.org"}
+ with Running(s) as base:
+ self.assertEqual(self.get(base, "/api/meta", "maps.example.org")[0], 200)
+ self.assertEqual(self.get(base, "/api/meta", "MAPS.example.org")[0], 200)
+ self.assertEqual(self.get(base, "/api/meta", "evil.example.com")[0], 403)
+ self.assertEqual(self.get(base, "/api/meta", f"localhost:{s.server_address[1]}")[0], 200)
+ meta = json.loads(request(base + "/api/meta")[1])
+ tile = meta["tiles"]["url"].replace("{layer}", "relief").replace("{z}", "5").replace("{x}", "0").replace("{y}", "0")
+ st, cache, _ = self.get(base, tile, "maps.example.org")
+ self.assertEqual(st, 200)
+ self.assertIn("immutable", cache)
+
+ def test_private_server_refuses_other_hosts_and_caches_tiles_a_day(self):
+ s = serve.make_server(self.root, 2, 0, pins_path=self.dir / "pins.json")
+ with Running(s) as base:
+ self.assertEqual(self.get(base, "/api/meta", "maps.example.org")[0], 403)
+ meta = json.loads(request(base + "/api/meta")[1])
+ tile = meta["tiles"]["url"].replace("{layer}", "relief").replace("{z}", "5").replace("{x}", "0").replace("{y}", "0")
+ self.assertEqual(self.get(base, tile, f"127.0.0.1:{s.server_address[1]}")[1], "max-age=86400")
+
+ def test_allow_host_requires_public(self):
+ with mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()), \
+ self.assertRaises(SystemExit): # not serving: refused before any server starts
+ serve.main(["--res", "2", "--port", "0", "--allow-host", "maps.example.org"], root=self.root)
+
+
+class TileCapServeTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.dir = Path(tempfile.mkdtemp())
+
+ def tearDown(self):
+ shutil.rmtree(self.dir)
+
+ def test_saved_tiles_count_and_disk_reads_touch(self):
+ from tilecap import TileCap
+ s = serve.make_server(self.root, 2, 0, pins_path=self.dir / "pins.json")
+ src = s.tiles
+ src.cache_root = self.dir / "tiles" # a fresh cache: this tile renders
+ cap = TileCap(10**9)
+ cap.add_root(src.cache_root)
+ cap.sweep()
+ src.cap = cap
+ z, x, y = 7, 100, 40
+ body = src.tile("relief", z, x, y, remember=False)
+ self.assertEqual(cap.total, len(body))
+ f = next(src.cache_root.rglob(f"relief/{z}/{x}/{y}.jpg"))
+ os.utime(f, (1, 1))
+ self.assertEqual(src.tile("relief", z, x, y, remember=False), body)
+ self.assertGreater(f.stat().st_mtime, 1e9, "a disk read marks the tile used")
+ s.server_close()
+
+ def test_main_tile_cache_options(self):
+ made = []
+ real = serve.make_server
+
+ def spy(*a, **k):
+ made.append(real(*a, **{**k, "workers": 0}))
+ return made[-1]
+ with socket.socket() as so:
+ so.bind(("127.0.0.1", 0))
+ port = so.getsockname()[1]
+ with mock.patch.object(serve, "make_server", spy), \
+ mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(serve.main(["--res", "2", "--port", str(port), "--tile-cache-gb", "1.5",
+ "--tile-keep-z", "9"], root=self.root), 0)
+ self.assertEqual(serve.main(["--res", "2", "--port", str(port)], root=self.root), 0)
+ cap = made[0].tiles.cap
+ self.assertEqual((cap.cap, cap.keep_z), (1_500_000_000, 9))
+ self.assertIn(made[0].tiles.cache_root, cap.roots)
+ self.assertIsNone(made[1].tiles.cap, "no cap unless asked")
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ serve.main(["--res", "2", "--tile-cache-gb", "0"], root=self.root)
+
+
+class RenderGateTest(unittest.TestCase):
+ def setUp(self):
+ self.root = built_world()
+ self.dir = Path(tempfile.mkdtemp())
+
+ def tearDown(self):
+ shutil.rmtree(self.dir)
+
+ def test_full_render_slots_answer_503_but_saved_tiles_still_serve(self):
+ s = serve.make_server(self.root, 2, 0, pins_path=self.dir / "pins.json")
+ s.tiles.cache_root = self.dir / "tiles"
+ s.render_gate = threading.BoundedSemaphore(1)
+ with Running(s) as base:
+ meta = json.loads(request(base + "/api/meta")[1])
+ url = lambda x: base + meta["tiles"]["url"].replace("{layer}", "relief").replace("{z}", "7") \
+ .replace("{x}", str(x)).replace("{y}", "40")
+ self.assertEqual(request(url(100))[0], 200) # a free slot: renders and saves
+ self.assertTrue(s.render_gate.acquire(blocking=False)) # every slot taken
+ try:
+ r = urllib.request.urlopen(url(101))
+ self.fail("rendered without a slot")
+ except urllib.error.HTTPError as e:
+ self.assertEqual(e.code, 503)
+ self.assertEqual(e.headers["Retry-After"], str(serve.BUSY_RETRY_S))
+ s.tiles.mem.clear()
+ self.assertEqual(request(url(100))[0], 200, "a saved tile needs no slot")
+ s.render_gate.release()
+ self.assertEqual(request(url(101))[0], 200, "a slot again: renders")
+
+ def test_main_render_limit_defaults(self):
+ made = []
+ real = serve.make_server
+
+ def spy(*a, **k):
+ made.append(real(*a, **{**k, "workers": 0}))
+ return made[-1]
+ with socket.socket() as so:
+ so.bind(("127.0.0.1", 0))
+ port = so.getsockname()[1]
+ argv = ["--res", "2", "--port", str(port), "--workers", "3"]
+ with mock.patch.object(serve, "make_server", spy), \
+ mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ contextlib.redirect_stdout(io.StringIO()):
+ for extra in ([], ["--public"], ["--public", "--max-renders", "5"]):
+ self.assertEqual(serve.main(argv + extra, root=self.root), 0)
+ self.assertIsNone(made[0].render_gate, "private: no limit")
+ self.assertEqual(made[1].render_gate._initial_value, 12) # 4 × 3 workers
+ self.assertEqual(made[2].render_gate._initial_value, 5)
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ serve.main(["--res", "2", "--max-renders", "0"], root=self.root)
+
+
+class EnvOptionsTest(unittest.TestCase):
+ def parser(self):
+ import argparse
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--port", type=int, default=8765)
+ ap.add_argument("--public", action="store_true")
+ ap.add_argument("--tile-cache-gb", type=float)
+ ap.add_argument("--world", action="append")
+ return ap
+
+ def test_env_values_are_defaults_and_flags_win(self):
+ ap = self.parser()
+ serve.env_defaults(ap, {"WORLDMAP_PORT": "9000", "WORLDMAP_PUBLIC": "yes", "WORLDMAP_TILE_CACHE_GB": "140",
+ "WORLDMAP_WORLD": "/ignored/here", "OTHER": "1"})
+ a = ap.parse_args([])
+ self.assertEqual((a.port, a.public, a.tile_cache_gb, a.world), (9000, True, 140.0, None))
+ self.assertEqual(ap.parse_args(["--port", "8000"]).port, 8000)
+ ap = self.parser()
+ serve.env_defaults(ap, {"WORLDMAP_PUBLIC": "off"})
+ self.assertFalse(ap.parse_args([]).public)
+
+ def test_bad_env_values_name_the_variable(self):
+ for env in ({"WORLDMAP_PORT": "x"}, {"WORLDMAP_PUBLIC": "maybe"}):
+ with self.assertRaisesRegex(ValueError, list(env)[0]):
+ serve.env_defaults(self.parser(), env)
+
+ def test_env_list(self):
+ self.assertEqual(serve.env_list("a.org, b.org c.org"), ["a.org", "b.org", "c.org"])
+
+ def test_main_reads_the_environment(self):
+ root = built_world()
+ made = []
+ real = serve.make_server
+
+ def spy(*a, **k):
+ made.append((k["bind"], real(*a, **{**k, "workers": 0, "bind": "127.0.0.1"})))
+ return made[-1][1]
+ with socket.socket() as so:
+ so.bind(("127.0.0.1", 0))
+ port = so.getsockname()[1]
+ env = {"WORLDMAP_PUBLIC": "1", "WORLDMAP_BIND": "0.0.0.0", "WORLDMAP_ALLOW_HOST": "maps.example.org,m.example.org",
+ "WORLDMAP_PORT": str(port), "WORLDMAP_TILE_CACHE_GB": "2", "WORLDMAP_RES": "2", "WORLDMAP_WORKERS": "3",
+ "WORLDMAP_WORLD": str(root)}
+ with mock.patch.dict(os.environ, env), mock.patch.object(serve, "make_server", spy), \
+ mock.patch.object(serve.ThreadingHTTPServer, "serve_forever", side_effect=KeyboardInterrupt), \
+ contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(serve.main([], root=Path("/nonexistent")), 0) # the world comes from the environment
+ bind, s = made[0]
+ self.assertEqual(bind, "0.0.0.0")
+ self.assertTrue(s.public)
+ self.assertEqual(s.allowed_hosts, {"maps.example.org", "m.example.org"})
+ self.assertEqual(s.tiles.cap.cap, 2_000_000_000)
+ self.assertEqual(s.render_gate._initial_value, 12)
+
+ def test_outside_bind_needs_public(self):
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ serve.main(["--res", "2", "--bind", "0.0.0.0"], root=built_world())
+
+
+class StaleStyleTest(unittest.TestCase):
+ def test_a_build_with_a_renamed_style_says_rebuild(self):
+ tmp = Path(tempfile.mkdtemp())
+ try:
+ root = tmp / "w"
+ shutil.copytree(built_world(), root)
+ meta = root / "out" / "r2" / "cells_meta.json"
+ m = json.loads(meta.read_text())
+ m["style"] = "dark-moon" # the moons' palette before its rename
+ meta.write_text(json.dumps(m))
+ with self.assertRaisesRegex(serve.MissingBuild, "rebuild"):
+ serve.World(root, 2, use_cache=False)
+ m["style"] = "tidal-lock"
+ meta.write_text(json.dumps(m))
+ self.assertEqual(serve.World(root, 2, use_cache=False).cells_meta["style"], "tidal-lock")
+ finally:
+ shutil.rmtree(tmp)
diff --git a/tests/test_servecache.py b/tests/test_servecache.py
new file mode 100644
index 0000000..6f45825
--- /dev/null
+++ b/tests/test_servecache.py
@@ -0,0 +1,182 @@
+# map/tests/test_servecache.py
+import os
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+
+import servecache as SC
+
+
+class ServeCacheTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp())
+ self.arrays = {"a": np.arange(10, dtype=np.float32), "ids": np.array([3, 1, 2], np.uint64),
+ "m": np.array([True, False]), "xyz": np.random.default_rng(1).normal(size=(5, 3))}
+
+ def test_round_trip_keeps_values_and_dtypes(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ self.assertEqual(d.name, f"serve-abc-v{SC.VERSION}")
+ SC.write(d, self.arrays, {"note": "x"})
+ arrays, meta = SC.read(d)
+ self.assertEqual(meta["note"], "x")
+ for k, v in self.arrays.items():
+ self.assertIsInstance(arrays[k], np.memmap)
+ self.assertEqual(arrays[k].dtype, v.dtype)
+ np.testing.assert_array_equal(arrays[k], v)
+
+ def test_read_loads_more_arrays_than_the_soft_file_limit(self):
+ import resource
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
+ if hard != resource.RLIM_INFINITY and hard < 400:
+ self.skipTest("hard open-file limit too low")
+ d = SC.cache_dir(self.tmp, "many")
+ SC.write(d, {f"f{i}": np.arange(3) + i for i in range(300)}, {})
+ got = None
+ try:
+ resource.setrlimit(resource.RLIMIT_NOFILE, (128, hard)) # each memory map keeps a file open
+ got = SC.read(d)
+ self.assertIsNotNone(got, "a complete cache must not read as damaged")
+ self.assertEqual(int(got[0]["f299"][2]), 301)
+ finally:
+ del got
+ resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
+
+ def test_read_ignores_a_folder_without_done(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ SC.write(d, self.arrays, {})
+ (d / "done").unlink()
+ self.assertIsNone(SC.read(d))
+ self.assertIsNone(SC.read(self.tmp / "missing"))
+
+ def test_failed_write_leaves_no_final_folder(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ with mock.patch.object(SC, "save_npy", side_effect=OSError("disk full")):
+ with self.assertRaises(OSError):
+ SC.write(d, self.arrays, {})
+ self.assertFalse(d.exists())
+ self.assertEqual(list(self.tmp.iterdir()), [], "the temporary folder is removed too")
+
+ def test_load_or_build_builds_once(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ calls = []
+ def build():
+ calls.append(1)
+ return self.arrays, {"n": 1}
+ a1, _ = SC.load_or_build(d, build)
+ a2, meta = SC.load_or_build(d, build)
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(meta["n"], 1)
+ self.assertIsInstance(a1["a"], np.memmap, "even the first load hands back the written maps")
+ np.testing.assert_array_equal(a2["xyz"], self.arrays["xyz"])
+
+ def test_unwritable_falls_back_to_ram(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ logs = []
+ with mock.patch.object(SC, "write", side_effect=OSError("read-only file system")):
+ arrays, _ = SC.load_or_build(d, lambda: (self.arrays, {}), log=logs.append)
+ self.assertIs(arrays["a"], self.arrays["a"])
+ self.assertEqual(len(logs), 1)
+ self.assertIn("read-only file system", logs[0])
+
+ def test_prune_removes_only_unkept_serve_folders(self):
+ keep = SC.cache_dir(self.tmp, "new")
+ old = SC.cache_dir(self.tmp, "old")
+ for d in (keep, old):
+ SC.write(d, self.arrays, {})
+ area = self.tmp / "0123abcd"
+ area.mkdir()
+ (area / "cells.npz").write_bytes(b"x")
+ tiles = self.tmp / "v4-abc"
+ tiles.mkdir()
+ gone = SC.prune(self.tmp, [keep])
+ self.assertEqual(gone, [old])
+ self.assertTrue(keep.exists() and area.exists() and tiles.exists())
+
+ def test_prune_keeps_young_temporary_folders_and_removes_old_ones(self):
+ young = self.tmp / f"serve-x-v{SC.VERSION}.tmp-1"
+ stale = self.tmp / f"serve-y-v{SC.VERSION}.tmp-2"
+ young.mkdir()
+ stale.mkdir()
+ t = time.time() - 7200
+ os.utime(stale, (t, t))
+ SC.prune(self.tmp, [])
+ self.assertTrue(young.exists(), "may belong to a server still writing it")
+ self.assertFalse(stale.exists())
+
+ def test_prune_removes_a_young_temporary_folder_of_a_process_that_is_gone(self):
+ import subprocess
+ pr = subprocess.Popen(["true"])
+ pr.wait()
+ orphan = self.tmp / f"serve-z-v{SC.VERSION}.tmp-{pr.pid}"
+ orphan.mkdir()
+ SC.prune(self.tmp, [])
+ self.assertFalse(orphan.exists(), "its writer died: nobody finishes it")
+
+ def test_prune_skips_a_folder_another_process_removed_meanwhile(self):
+ gone = self.tmp / f"serve-q-v{SC.VERSION}.tmp-1"
+ with mock.patch.object(Path, "iterdir", lambda _: iter([gone])), \
+ mock.patch.object(Path, "is_dir", lambda _: True):
+ self.assertEqual(SC.prune(self.tmp, []), [])
+
+ def test_another_server_finishing_first_is_no_failure(self):
+ import errno
+ d = SC.cache_dir(self.tmp, "race")
+ real = os.rename
+
+ def other_first(src, dst):
+ with mock.patch.object(SC.os, "rename", real):
+ SC.write(Path(dst), self.arrays, {})
+ raise OSError(errno.ENOTEMPTY, "Directory not empty")
+ logs = []
+ with mock.patch.object(SC.os, "rename", other_first):
+ arrays, _ = SC.load_or_build(d, lambda: (self.arrays, {}), log=logs.append)
+ self.assertEqual(logs, [], "no RAM fallback")
+ self.assertIsInstance(arrays["a"], np.memmap)
+ self.assertFalse([p for p in self.tmp.iterdir() if ".tmp-" in p.name])
+
+ def test_a_damaged_cache_is_rebuilt(self):
+ for damage in ("empty", "truncated"):
+ d = SC.cache_dir(self.tmp, damage)
+ SC.write(d, self.arrays, {})
+ f = d / "xyz.npy"
+ f.write_bytes(b"" if damage == "empty" else f.read_bytes()[:200]) # e.g. power lost after the rename
+ logs = []
+ arrays, _ = SC.load_or_build(d, lambda: (self.arrays, {}), log=logs.append)
+ self.assertIsInstance(arrays["xyz"], np.memmap, damage)
+ np.testing.assert_array_equal(arrays["xyz"], self.arrays["xyz"])
+ self.assertIsNotNone(SC.read(d), f"{damage}: healed for the next start")
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class SaveNpyTest(unittest.TestCase):
+ def test_same_bytes_as_np_save(self):
+ rng = np.random.default_rng(0)
+ arrays = [rng.normal(size=3_000_000), rng.random((1000, 3)), np.zeros(0), np.array(3.5),
+ rng.integers(0, 9, 50_000).astype(np.int8), rng.random(10_000) < 0.5,
+ np.arange(12, dtype=np.uint64).reshape(3, 4)[:, ::2], np.asfortranarray(rng.random((300, 7)))]
+ with tempfile.TemporaryDirectory() as t:
+ for i, a in enumerate(arrays):
+ with self.subTest(i=i, dtype=str(a.dtype), shape=a.shape):
+ np.save(Path(t) / "want.npy", np.ascontiguousarray(a), allow_pickle=False)
+ SC.save_npy(Path(t) / "got.npy", a)
+ self.assertEqual((Path(t) / "got.npy").read_bytes(), (Path(t) / "want.npy").read_bytes())
+
+ def test_written_in_plain_writes_not_tofile(self):
+ """numpy's tofile() path preallocates the file; btrfs then stores it uncompressed."""
+ from numpy.lib import format as F
+ seen = []
+ real = F.write_array
+ def spy(fp, *a, **k):
+ seen.append(F.isfileobj(fp))
+ return real(fp, *a, **k)
+ with tempfile.TemporaryDirectory() as t, mock.patch.object(F, "write_array", spy):
+ SC.save_npy(Path(t) / "a.npy", np.ones(1000))
+ self.assertTrue(np.array_equal(np.load(Path(t) / "a.npy"), np.ones(1000)))
+ self.assertEqual(seen, [False])
diff --git a/tests/test_tilecap.py b/tests/test_tilecap.py
new file mode 100644
index 0000000..fbcb5e7
--- /dev/null
+++ b/tests/test_tilecap.py
@@ -0,0 +1,81 @@
+import os
+import shutil
+import tempfile
+import time
+import unittest
+from pathlib import Path
+
+from tilecap import TileCap, tile_key
+
+
+class TileCapTest(unittest.TestCase):
+ def setUp(self):
+ self.root = Path(tempfile.mkdtemp())
+
+ def tearDown(self):
+ shutil.rmtree(self.root)
+
+ def put(self, rel, size, age):
+ f = self.root / "v5.4-abc" / rel
+ f.parent.mkdir(parents=True, exist_ok=True)
+ f.write_bytes(b"x" * size)
+ t = time.time() - age
+ os.utime(f, (t, t))
+ return f
+
+ def test_tile_key(self):
+ self.assertEqual(tile_key("t/v5/relief/9/12/34.jpg"), ("relief", 9))
+ self.assertEqual(tile_key("t/v5/mesh/12/1/2.bin"), ("mesh", 12))
+ self.assertIsNone(tile_key("t/v5/manifest.npz"))
+ self.assertIsNone(tile_key("t/v5/relief/9/12/tmpab.part"))
+ self.assertIsNone(tile_key("t/v5/relief/9/x/3.jpg"))
+
+ def test_sweep_deletes_least_recently_used_down_to_slack(self):
+ # 10 deep tiles of 100 B, the oldest first; cap 700 → goal 630: the 4 oldest go (1000 → 600)
+ fs = [self.put(f"relief/12/{i}/0.jpg", 100, age=100 - i) for i in range(10)]
+ keep = self.put("relief/5/0/0.jpg", 5000, age=1000) # prerendered: kept and not counted
+ other = self.put("biomes/5/0/0.jpg", 100, age=1) # another layer at a kept zoom: counted
+ man = self.put("manifest.npz", 100, age=1000) # not a tile
+ cap = TileCap(700, keep_z=9)
+ cap.add_root(self.root)
+ freed = cap.sweep()
+ self.assertEqual(freed, 500) # 1100 → 600: the 5 oldest of 11 counted
+ self.assertEqual([f.exists() for f in fs], [False] * 5 + [True] * 5)
+ self.assertTrue(keep.exists() and other.exists() and man.exists())
+ self.assertEqual(cap.total, 600)
+ self.assertFalse((self.root / "v5.4-abc" / "relief" / "12" / "0").exists(), "empty folders go")
+
+ def test_used_tile_survives(self):
+ fs = [self.put(f"relief/12/{i}/0.jpg", 100, age=100 - i) for i in range(4)]
+ cap = TileCap(300, keep_z=-1)
+ cap.add_root(self.root)
+ cap.used(fs[0]) # the oldest is read again
+ cap.sweep() # 400 → goal 270: two go
+ self.assertEqual([f.exists() for f in fs], [True, False, False, True])
+
+ def test_under_cap_deletes_nothing_and_saves_count(self):
+ fs = [self.put(f"relief/12/{i}/0.jpg", 100, age=10) for i in range(3)]
+ cap = TileCap(1000)
+ cap.add_root(self.root)
+ self.assertEqual(cap.sweep(), 0)
+ self.assertEqual(cap.total, 300)
+ cap.saved(self.root / "v5.4-abc" / "relief" / "5" / "0" / "0.jpg", 50)
+ self.assertEqual(cap.total, 350)
+ self.assertTrue(all(f.exists() for f in fs))
+
+ def test_save_past_cap_sweeps(self):
+ cap = TileCap(250)
+ cap.add_root(self.root)
+ cap.sweep()
+ fs = [self.put(f"mesh/13/{i}/0.bin", 100, age=10 - i) for i in range(3)]
+ started = []
+ cap.start = lambda: started.append(1)
+ cap.saved(fs[0], 100)
+ cap.saved(fs[1], 100)
+ self.assertEqual(started, [])
+ cap.saved(fs[2], 100)
+ self.assertEqual(started, [1])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_tiles.py b/tests/test_tiles.py
new file mode 100644
index 0000000..bf0e618
--- /dev/null
+++ b/tests/test_tiles.py
@@ -0,0 +1,1383 @@
+import io
+import json
+import os
+import shutil
+import tempfile
+import threading
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+from PIL import Image
+
+from mapgen.sphere import latlon_to_xyz
+
+import serve
+import tiles as T
+from tests.test_serve import Running, built_world, request
+from tests.helpers import big_river
+
+
+def tile_at(lat, lon, z):
+ span = 180.0 / 2 ** z
+ return int((lon + 180) // span), int((90 - lat) // span)
+
+
+class TilesTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.root = built_world()
+ cls.cache = Path(tempfile.mkdtemp())
+ cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=cls.cache)
+
+ @classmethod
+ def tearDownClass(cls):
+ shutil.rmtree(cls.cache)
+
+ def test_tile_scheme(self):
+ self.assertTrue(T.tile_ok(0, 1, 0))
+ self.assertFalse(T.tile_ok(0, 2, 0))
+ self.assertFalse(T.tile_ok(3, 0, 8))
+ self.assertFalse(T.tile_ok(17, 0, 0))
+ lat, lon = T.tile_lat_lon(0, 0, 0, np.array([0, 255]))
+ self.assertAlmostEqual(lat[0], 90 - 0.5 / 256 * 180)
+ self.assertAlmostEqual(lon[1], -180 + 255.5 / 256 * 180)
+
+ def test_coarse_zoom_is_the_build_raster(self):
+ f = self.src.fields(0, 0, 0, 0, ("z", "cat"))
+ base = self.src.bilinear(self.src.surface(), f["lat"], f["lon"])
+ dry = ~f["lake"].astype(bool) # (lakes lie flat at their level)
+ np.testing.assert_array_equal(f["z"][dry], base[dry]) # the 8 km detail is below this zoom's pixel size
+
+ def test_detail_appears_when_zoomed_in(self):
+ f = self.src.fields(12, *tile_at(10.0, 20.0, 12), 0, ("z",))
+ base = self.src.bilinear(self.src.raster("elevation"), f["lat"], f["lon"])
+ self.assertGreater(float(np.std(f["z"] - base)), 1.0)
+ self.assertTrue(np.all(np.isfinite(f["z"])))
+
+ def test_seams_match(self):
+ z, x, y = 7, *tile_at(5.0, 10.0, 7)
+ a = self.src.fields(z, x, y, 1, ("z", "cat"))
+ east = self.src.fields(z, x + 1, y, 1, ("z", "cat"))
+ south = self.src.fields(z, x, y + 1, 1, ("z", "cat"))
+ np.testing.assert_allclose(a["z"][:, -1], east["z"][:, 1], atol=1e-6)
+ np.testing.assert_array_equal(a["holdridge"][:, -1], east["holdridge"][:, 1])
+ np.testing.assert_allclose(a["z"][-1, :], south["z"][1, :], atol=1e-6)
+
+ def test_antimeridian_seam(self):
+ z = 6
+ y = tile_at(0.0, 0.0, z)[1]
+ west = self.src.fields(z, 2 ** (z + 1) - 1, y, 1, ("z",))
+ east = self.src.fields(z, 0, y, 1, ("z",))
+ np.testing.assert_allclose(west["z"][:, -1], east["z"][:, 1], atol=1e-6)
+
+ def test_detail_amplitude_is_continuous(self):
+ # blending landform amplitudes across cells must not jump where the set of nearest cells changes
+ lon = np.linspace(-60.0, 60.0, 40001)
+ amp, ridge, sea = self.src._amp(latlon_to_xyz(np.full_like(lon, 10.0), lon))
+ for a in (amp, ridge, sea):
+ span = a.max() - a.min()
+ if span > 0:
+ self.assertLess(np.abs(np.diff(a)).max(), 0.02 * span)
+
+ def test_coast_rule_is_continuous(self):
+ sea = np.linspace(0.0, 1.0, 1001)
+ for z in (-3000.0, -100.0, 0.0, 100.0, 3000.0):
+ w = T.coast_water(np.full_like(sea, z), sea)
+ self.assertFalse(w[sea < 0.001].any())
+ self.assertTrue(w[sea > 0.999].all())
+ self.assertEqual(int(np.sum(np.diff(w.astype(int)) != 0)), 1, z) # one clean switch, no hard cut at a threshold
+ self.assertEqual(bool(T.coast_water(np.array([z]), np.array([0.5]))[0]), z < 0)
+ # below sea level, water reaches further into the mixed band than a flat threshold would allow
+ self.assertTrue(T.coast_water(np.array([-3000.0]), np.array([0.05]))[0])
+
+ def test_coast_band(self):
+ f = self.src.fields(4, *tile_at(10.0, -70.0, 4), 0, ("z", "water")) # an 11° tile over the small world's west coast
+ self.assertTrue(f["water"][f["sea"] > 0.999].all())
+ self.assertFalse(f["water"][f["sea"] < 0.001].any())
+ self.assertTrue(f["water"].any() and (~f["water"]).any(), "tile should hold both land and sea")
+
+ def test_layers_render(self):
+ for layer in T.LAYERS:
+ im = Image.open(io.BytesIO(self.src.tile(layer, 5, *tile_at(10.0, 20.0, 5))))
+ self.assertEqual((im.format, im.size), ("JPEG", (256, 256)), layer)
+ h = Image.open(io.BytesIO(self.src.tile("height", 5, *tile_at(10.0, 20.0, 5))))
+ self.assertEqual((h.format, h.size), ("PNG", (256, 256)))
+ self.assertIn(h.mode, ("I;16", "I"))
+ for y in (0, 2 ** 5 - 1): # polar rows
+ Image.open(io.BytesIO(self.src.tile("relief", 5, 3, y))).load()
+
+ def test_deterministic_and_cached(self):
+ args = ("relief", 9, *tile_at(12.0, 25.0, 9))
+ a = self.src.tile(*args)
+ other = T.TileSource(serve.World(self.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+ self.assertEqual(a, other.render(*args))
+ self.assertTrue((self.cache / self.src.tag / "relief" / "9").is_dir())
+ t = time.time()
+ self.src.tile(*args)
+ self.assertLess(time.time() - t, 0.05)
+
+ def test_speed(self):
+ t = time.time()
+ self.src.render("relief", 14, *tile_at(3.0, 5.0, 14))
+ self.assertLess(time.time() - t, 3.0)
+
+ def test_fingerprint_follows_the_build(self):
+ import os
+ a = T.TileSource(serve.World(self.root, 2), seed=7)
+ self.assertEqual(a.fingerprint, T.TileSource(serve.World(self.root, 2), seed=7).fingerprint)
+ self.assertNotEqual(a.fingerprint, T.TileSource(serve.World(self.root, 2), seed=8).fingerprint)
+ self.assertIn(a.fingerprint, str(a.cache_dir))
+ self.assertIn(a.fingerprint, a.meta()["url"])
+ elev = self.root / "out" / "r2" / "raster" / "elevation.png"
+ st = elev.stat()
+ os.utime(elev, ns=(st.st_atime_ns, st.st_mtime_ns + 10**9)) # a rebuild rewrites the rasters
+ self.assertNotEqual(a.fingerprint, T.TileSource(serve.World(self.root, 2), seed=7).fingerprint)
+
+ def test_z_at(self):
+ z = self.src.z_at(10.0, 20.0)
+ self.assertEqual(z.shape, (1,))
+ self.assertTrue(np.isfinite(z[0]))
+
+
+ def test_mesh_heights(self):
+ z, (x, y) = 9, tile_at(40.0, 10.0, 9)
+ h = self.src.mesh(z, x, y)[0]
+ self.assertEqual(h.shape, (T.MESH_N, T.MESH_N))
+ self.assertTrue(np.isfinite(h).all())
+ np.testing.assert_array_equal(h[:, -1], self.src.mesh(z, x + 1, y)[0][:, 0]) # shared edges: same points, same heights
+ np.testing.assert_array_equal(h[-1, :], self.src.mesh(z, x, y + 1)[0][0, :])
+ n = 2 ** (z + 1)
+ np.testing.assert_allclose(self.src.mesh(z, n - 1, y)[0][:, -1], self.src.mesh(z, 0, y)[0][:, 0], atol=1e-3)
+
+ def test_mesh_true_heights_and_water(self):
+ for x in range(64): # a coastal z5 tile
+ h, water = self.src.mesh(5, x, 12)
+ if water.any() and (~water).any():
+ break
+ else:
+ self.fail("no coastal z5 tile on row 12")
+ span = 180.0 / 32
+ f = np.arange(T.MESH_N) / (T.MESH_N - 1)
+ LA, LO = np.meshgrid(90.0 - (12 + f) * span, -180.0 + (x + f) * span, indexing="ij")
+ z, wet = self.src.surface_at(LA.ravel(), LO.ravel(), span / 128 * np.pi / 180 * self.src.R)
+ np.testing.assert_allclose(h, z.reshape(h.shape), atol=1e-6)
+ self.assertLess(float(h[water].min()), -100.0, "the sea floor, not the water surface")
+ self.assertTrue(np.all(h[water] < 60.0), "water only where the coast rule puts sea")
+
+ def test_mesh_cached_and_fast(self):
+ args = (11, *tile_at(-20.0, -35.0, 11))
+ t = time.time()
+ b = self.src.tile("mesh", *args)
+ self.assertLess(time.time() - t, 0.5)
+ self.assertEqual(len(b), T.MESH_BYTES)
+ h, water = self.src.mesh(*args)
+ n2 = T.MESH_N * T.MESH_N
+ np.testing.assert_array_equal(np.frombuffer(b[:4 * n2], "<f4"), h.astype("<f4").ravel())
+ np.testing.assert_array_equal(np.frombuffer(b[4 * n2:], np.uint8), water.astype(np.uint8).ravel())
+ self.assertTrue((self.cache / self.src.tag / "mesh" / "11" / str(args[1]) / f"{args[2]}.bin").is_file())
+
+ def test_a_saved_mesh_of_the_wrong_size_is_rendered_again(self):
+ args = (10, *tile_at(-21.0, -36.0, 10))
+ f = self.src.cache_path("mesh", *args)
+ f.parent.mkdir(parents=True, exist_ok=True)
+ f.write_bytes(b"\0" * (T.MESH_N * T.MESH_N * 4))
+ self.assertEqual(len(self.src.tile("mesh", *args, remember=False)), T.MESH_BYTES)
+ self.assertEqual(f.stat().st_size, T.MESH_BYTES)
+
+ def test_surface_matches_the_tile_pixels(self):
+ z, (x, y) = 8, tile_at(10.0, -70.0, 8) # the test world's west coast
+ f = self.src.fields(z, x, y, 0, ("z", "cat"))
+ zz, water = self.src.surface_at(f["lat"].ravel(), f["lon"].ravel(), f["px_km"])
+ tile_water = (f["water"] | f["lake"].astype(bool)).ravel()
+ self.assertLess(np.mean(water != tile_water), 0.02)
+ self.assertLess(np.mean(np.abs(zz - f["z"].ravel())), 10.0)
+
+
+ def _river_tile(self, z):
+ net = self.src.river_net
+ a = self.src.w.arrays
+ wet = a["lake"].astype(bool) | a["ocean"].astype(bool)
+ ok = a["river"][net.seg_a].astype(bool) & ~wet[net.seg_a] & ~wet[a["recv"][net.seg_a]] # on over land
+ i = int(net.seg_a[np.argmax(np.where(ok, net.half_w, 0))]) # the widest river
+ xyz = self.src.w.arrays["g_xyz"][i]
+ lat, lon = np.degrees(np.arcsin(xyz[2])), np.degrees(np.arctan2(xyz[1], xyz[0]))
+ return (z, *tile_at(lat, lon, z))
+
+ def test_valleys_cut_never_raise_and_hold_water(self):
+ args = self._river_tile(9)
+ f = self.src.fields(*args, 0, ("z", "cat"))
+ raw = self.src.fields(*args, 0, ("z", "cat"), valleys=False)
+ rel = self.src.relief_at(f["lat"].ravel(), f["lon"].ravel(), f["px_km"]).reshape(f["z"].shape)
+ self.assertTrue(np.all(f["z"] <= np.maximum(raw["z"], rel) + 1.0), "at most a micro-relief dip is kept dry")
+ self.assertTrue(f["river"].any(), "the river channel is water in its tile")
+ self.assertTrue((raw["z"] - f["z"]).max() > 1.0, "a valley was cut")
+
+ def test_valley_seams_match(self):
+ z, x, y = self._river_tile(10)
+ a = self.src.fields(z, x, y, 1, ("z",))
+ east = self.src.fields(z, x + 1, y, 1, ("z",))
+ np.testing.assert_allclose(a["z"][:, -1], east["z"][:, 1], atol=1e-6)
+
+ def test_valley_tiles_stay_fast_near_big_rivers(self):
+ for z in (12, 16):
+ args = self._river_tile(z)
+ t = time.time()
+ self.src.render("relief", *args)
+ self.assertLess(time.time() - t, 1.5, f"z{z}")
+ t = time.time()
+ self.src.surface_at(np.linspace(-40, 40, 256), np.full(256, 20.0), 20.0) # a 17,800 km profile
+ self.assertLess(time.time() - t, 0.5)
+
+ def test_valleys_fade_in(self):
+ z, x, y = self._river_tile(5)
+ f = self.src.fields(z, x, y, 0, ("z",))
+ raw = self.src.fields(z, x, y, 0, ("z",), valleys=False)
+ self.assertLess(np.abs(f["z"] - raw["z"]).max(), 2000.0)
+
+
+ def test_relief_draws_the_river(self):
+ args = self._river_tile(11)
+ f = self.src.fields(*args, 1, ("z", "water", "cat"))
+ img = self.src.rgb("relief", *args)
+ lake = np.array([79, 143, 192])
+ river = f["river_draw"][1:-1, 1:-1] & ~f["water"][1:-1, 1:-1]
+ self.assertTrue(river.any())
+ shade = img[river].astype(float) / lake
+ self.assertTrue(np.all(np.abs(shade - shade[:, :1]) < 0.02), "river pixels are the (shaded) water colour")
+
+ def test_tiles_draw_micro_relief_over_the_refine_surface(self):
+ z, x, y = 15, *tile_at(12.0, 25.0, 15)
+ f = self.src.fields(z, x, y, 0, ("z",), valleys=False)
+ rel = self.src.relief_at(f["lat"].ravel(), f["lon"].ravel(), f["px_km"]).reshape(f["z"].shape)
+ d = f["z"] - rel # relief_at (refine's start) stays without it
+ self.assertGreater(float(np.std(d)), 0.5)
+ self.assertLess(float(np.abs(d).max()), 60.0)
+
+ def test_micro_relief_never_digs_dry_ground_below_the_river(self):
+ z, x, y = self._river_tile(14)
+ f = self.src.fields(z, x, y, 0, ("z",))
+ ch, zz = f["river"], f["z"]
+ self.assertTrue(ch.any() and (~ch).any())
+ for s in ((0, 1), (0, -1), (1, 0), (-1, 0)): # dry pixels beside the channel: not below its water
+ wet, h = np.roll(ch, s, (0, 1)), np.roll(zz, s, (0, 1))
+ dry = wet & ~ch
+ dry[[0, -1], :] = dry[:, [0, -1]] = False
+ self.assertTrue(np.all(zz[dry] >= h[dry] - 1e-6), s)
+
+ def test_land_colour_varies_within_one_zone(self):
+ args = (13, *tile_at(12.0, 25.0, 13))
+ f = self.src.fields(*args, 0, ("z", "water", "cat"))
+ img = self.src.rgb("relief", *args).astype(float)
+ land = ~f["water"] & ~f["lake"].astype(bool) & ~f["river_draw"] & (f["ice"] == 0)
+ zone = f["holdridge"] == np.bincount(f["holdridge"][land]).argmax()
+ hue = (img[..., 0] - img[..., 1]) / (img[..., 0] + img[..., 1] + 1.0)
+ self.assertGreater(float(np.std(hue[land & zone])), 0.004, "not one flat colour under the hillshade")
+ self.assertLess(float(np.std(hue[land & zone])), 0.05)
+
+
+class RegionTilesTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import refine as RF
+ cls.root = built_world()
+ from tests.test_refine import clear_regions
+ clear_regions(cls.root)
+ w = RF.WorldCells(cls.root / "out" / "r2")
+ i = big_river(w.a)
+ cls.lat, cls.lon = float(w.a["g_lat"][i]), float(w.a["g_lon"][i])
+ reg = cls.root / "places" / "regions.json"
+ reg.parent.mkdir(exist_ok=True)
+ d = 3
+ reg.write_text(json.dumps({"regions": [{"id": "r", "name": "t", "outline": [[cls.lat - d, cls.lon - d], [cls.lat - d, cls.lon + d], [cls.lat + d, cls.lon + d], [cls.lat + d, cls.lon - d]]}]}))
+ from mapgen import config as C
+ seed = int(C.load(cls.root)[0]["build"]["seed"]) # the build's seed, as the server uses
+ cls.plain = T.TileSource(serve.World(cls.root, 2), seed=seed, cache_dir=Path(tempfile.mkdtemp()))
+ RF.build_areas(cls.root, 2, reg, log=lambda s: None)
+ cls.src = T.TileSource(serve.World(cls.root, 2), seed=seed, cache_dir=Path(tempfile.mkdtemp()))
+
+ @classmethod
+ def tearDownClass(cls):
+ from tests.test_refine import clear_regions
+ clear_regions(cls.root)
+
+ def test_tag_changes_with_regions(self):
+ self.assertFalse(self.src.regions.empty)
+ self.assertNotEqual(self.src.tag, self.plain.tag)
+ self.assertRegex(self.src.tag, r"^v\d+\.\d+-[0-9a-f]+$")
+
+ def test_region_tile_urls_follow_the_region_rendering_version(self):
+ from unittest import mock
+ with mock.patch.object(T, "REGION_VERSION", T.REGION_VERSION + 1):
+ other = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ self.assertNotEqual(other.tag, self.src.tag, "new region rendering: new tile URLs, no stale saved tiles")
+ self.assertEqual(other.base_tag, self.src.base_tag, "the world's own tiles stay")
+ self.assertRegex(other.tag, r"^v\d+\.\d+-[0-9a-f]+$")
+
+ def test_inside_uses_refined_heights(self):
+ z, (x, y) = 8, tile_at(self.lat, self.lon, 8)
+ a, b = self.src.fields(z, x, y, 0, ("z",)), self.plain.fields(z, x, y, 0, ("z",))
+ self.assertGreater(np.abs(a["z"] - b["z"]).mean(), 1.0)
+
+ def test_region_edge_seams(self):
+ for dx in (-3, -2, 2, 3):
+ z = 8
+ x, y = tile_at(self.lat, self.lon + dx, z)
+ a, e = self.src.fields(z, x, y, 1, ("z",)), self.src.fields(z, x + 1, y, 1, ("z",))
+ np.testing.assert_allclose(a["z"][:, -1], e["z"][:, 1], atol=1e-6)
+
+ def test_blend_band_has_no_step(self):
+ lat = np.full(400, self.lat)
+ lon = self.lon + np.linspace(2.0, 4.0, 400) # across the east edge (lon + 3)
+ z = self.src.z_at(lat, lon, 1.0)
+ xyz = latlon_to_xyz(lat, lon).reshape(-1, 3)
+ lake = self.src._flat_lakes(np.zeros(len(lat)), lat, lon, *self.src._warp_offsets(xyz, 4.0)) != 0
+ shore = lake[1:] != lake[:-1] # (a world lake's shore may be a bank)
+ k = int(np.argmax(np.where(shore, 0.0, np.abs(np.diff(z)))))
+ fine = np.linspace(lon[k], lon[k + 1], 41) # 10× finer around the worst step
+ zf = self.src.z_at(np.full(41, self.lat), fine, 1.0)
+ self.assertLess(np.abs(np.diff(zf)).max(), 0.3 * abs(z[k + 1] - z[k]) + 1.0,
+ "continuous: the worst step shrinks with the sample spacing (a cliff would not)")
+
+ def test_outside_unchanged_and_no_regions_unchanged(self):
+ z, (x, y) = 8, tile_at(self.lat + 20, self.lon, 8)
+ np.testing.assert_array_equal(self.src.fields(z, x, y, 0, ("z",))["z"], self.plain.fields(z, x, y, 0, ("z",))["z"])
+ self.assertTrue(self.plain.regions.empty)
+
+ def test_inspector_answers_refined(self):
+ w = serve.World(self.root, 2)
+ w.regions = self.src.regions
+ d = w.describe(self.lat, self.lon)
+ self.assertTrue(d.get("refined"))
+ far = w.describe(self.lat + 20, self.lon)
+ self.assertFalse(far.get("refined", False))
+
+
+ def _exit(self):
+ import refine as RF
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ a = dict(np.load(d / "cells.npz"))
+ halo = a["halo"].astype(bool)
+ feed = np.where(a["river"].astype(bool) & ~halo & halo[a["recv"]])[0]
+ return a, feed
+
+ def test_rivers_run_on_to_the_halo(self):
+ a, feed = self._exit()
+ net = self.src.regions.net
+ self.assertTrue(len(feed), "the test area has rivers leaving it")
+ self.assertTrue(np.all(net.seg_len > 0), "no zero-length segments")
+ ends = {tuple(np.round(v, 9)) for v in net.b_xyz}
+ for i in feed:
+ self.assertIn(tuple(np.round(a["g_xyz"][a["recv"][i]], 9)), ends, "the river reaches its halo cell")
+
+ def test_world_rivers_stay_drawn_across_the_blend_band(self):
+ a, feed = self._exit()
+ found = False
+ for i in feed:
+ lat, lon = float(a["g_lat"][a["recv"][i]]), float(a["g_lon"][a["recv"][i]])
+ for dz in (7, 8):
+ x, y = tile_at(lat, lon, dz)
+ f, g = self.src.fields(dz, x, y, 0, ("z",)), self.plain.fields(dz, x, y, 0, ("z",))
+ xyz = latlon_to_xyz(f["lat"], f["lon"]).reshape(-1, 3)
+ w = self.src.regions.weight(xyz).reshape(f["z"].shape)
+ band = g["river_draw"] & (w < 1)
+ if band.any():
+ found = True
+ self.assertTrue(np.all(f["river_draw"][band]), "world rivers drawn wherever the world still shows")
+ self.assertTrue(found)
+
+ def test_region_valleys_reach_up_to_the_shoulders(self):
+ import refine as RF
+ import rivers as RV
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ tmp = Path(tempfile.mkdtemp())
+ dst = RF.results_dir(tmp, "k") / d.name
+ shutil.copytree(d, dst)
+ a = dict(np.load(dst / "cells.npz"))
+ src = ~np.isin(np.arange(len(a["recv"])), a["recv"][a["recv"] != np.arange(len(a["recv"]))])
+ dry = src & ~a["halo"].astype(bool) & ~a["ocean"].astype(bool) & ~a["lake"].astype(bool) # (coarse test cells
+ a["river"] = a["river"].astype(bool) & ~dry # are all rivers)
+ a["z_surface_m"] = np.where(dry, a["z_surface_m"] + 500.0, a["z_surface_m"]) # canyons
+ np.savez(dst / "cells.npz", **a)
+ rs = RF.RegionSet(tmp, "k", self.src.w.legends, self.src.R)
+ plain = RV.RiverNet(rs.arrays, self.src.w.legends, rs.R, levels=rs.arrays["z_surface_m"])
+ self.assertTrue(np.any(rs.net.reach[: len(plain.reach)] > plain.reach + 1e-9), "walls reach up to the shoulders")
+
+ def test_profile_inside_ignores_world_lakes(self):
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ src.w.arrays = dict(src.w.arrays, lake=np.ones(len(src.w.arrays["g_ids"]), bool)) # lakes everywhere
+ lat, lon = np.full(50, self.lat), self.lon + np.linspace(-0.5, 0.5, 50)
+ np.testing.assert_array_equal(src.surface_at(lat, lon)[1], self.src.surface_at(lat, lon)[1])
+
+ def test_inspector_band_answers_from_the_world(self):
+ rs = self.src.regions
+ a, feed = self._exit()
+ pa, pb = a["g_xyz"][feed[0]], a["g_xyz"][a["recv"][feed[0]]]
+ pts = pa + np.linspace(0.0, 0.6, 121)[:, None] * (pb - pa) # from the border cell toward the halo
+ pts /= np.linalg.norm(pts, axis=1, keepdims=True)
+ lat, lon = np.degrees(np.arcsin(pts[:, 2])), np.degrees(np.arctan2(pts[:, 1], pts[:, 0]))
+ band = [k for k in range(len(pts)) if rs.weight(pts[k:k + 1])[0] < 0.5 and rs.index_of(lat[k], lon[k]) is not None]
+ self.assertTrue(band, "a point of a refined cell in the outer blend band")
+ world = serve.World(self.root, 2)
+ world.regions = rs
+ d = world.describe(float(lat[band[0]]), float(lon[band[0]]))
+ self.assertFalse(d["refined"])
+ self.assertEqual(d["inner_km"], 0.0, "no reuse of an answer next to a refined area")
+
+ def test_a_server_without_regions_leaves_region_tile_caches_alone(self):
+ root = self.root / "out" / "r2" / "tiles"
+ plain = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, regions_dir=Path(tempfile.mkdtemp())) # no regions
+ other_world, region = root / "v1-deadbeef00", root / (plain.base_tag + "abc123")
+ for d in (other_world, region):
+ d.mkdir(parents=True, exist_ok=True)
+ plain.warm()
+ self.assertFalse(other_world.exists(), "another world's tiles go")
+ self.assertTrue(region.exists(), "a smoke server must not wipe the real server's region tiles")
+ shutil.rmtree(region)
+
+ def test_worker_processes_render_the_same_tiles(self):
+ from concurrent.futures import ThreadPoolExecutor
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ src.start_workers(2)
+ try:
+ args = [("relief", 8, *tile_at(self.lat, self.lon, 8)), ("mesh", 8, *tile_at(self.lat, self.lon, 8)),
+ ("relief", 6, *tile_at(self.lat + 20, self.lon, 6))]
+ with ThreadPoolExecutor(3) as ex:
+ got = list(ex.map(lambda a: src.tile(*a), args))
+ self.assertEqual(got, [self.src.render(*a) for a in args])
+ self.assertEqual(src.pool.alive, 2)
+ finally:
+ src.pool.close()
+
+ def test_workers_exit_when_the_server_process_dies(self):
+ import os
+ import signal
+ import subprocess
+ import sys
+ import textwrap
+ code = textwrap.dedent(f"""
+ import sys, socket, tempfile, pathlib; sys.path.insert(0, {str(Path(T.__file__).parent)!r})
+ import tiles as T, serve
+ lsock = socket.socket(); lsock.bind(("127.0.0.1", 0)); lsock.listen() # a server's listening socket
+ src = T.TileSource(serve.World(pathlib.Path({str(self.root)!r}), 2), seed=7, cache_dir=pathlib.Path(tempfile.mkdtemp()))
+ src.start_workers(2)
+ print(lsock.getsockname()[1], *[p.pid for p in src.pool.procs], flush=True)
+ import time; time.sleep(60)
+ """)
+ p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
+ port, *pids = map(int, p.stdout.readline().split())
+ os.kill(p.pid, signal.SIGKILL) # no atexit: as a killed server
+ p.wait()
+ deadline = time.time() + 10
+ while time.time() < deadline and any(os.path.exists(f"/proc/{q}") and open(f"/proc/{q}/stat").read().split()[2] != "Z"
+ for q in pids):
+ time.sleep(0.2)
+ alive = [q for q in pids if os.path.exists(f"/proc/{q}") and open(f"/proc/{q}/stat").read().split()[2] != "Z"]
+ for q in alive:
+ os.kill(q, signal.SIGKILL)
+ self.assertEqual(alive, [], "orphaned render workers linger")
+ import socket
+ s = socket.socket()
+ self.assertNotEqual(s.connect_ex(("127.0.0.1", port)), 0, "nobody still holds the server's listening socket")
+ s.close()
+
+ def test_workers_follow_a_region_reload(self):
+ import refine as RF
+ empty = Path(tempfile.mkdtemp())
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()),
+ regions_dir=empty)
+ src.start_workers(1)
+ try:
+ args = ("relief", 8, *tile_at(self.lat, self.lon, 8))
+ self.assertEqual(src.tile(*args), self.plain.render(*args))
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ shutil.copytree(d, RF.results_dir(empty, src.fingerprint) / d.name)
+ src.reload_regions()
+ self.assertEqual(src.tile(*args), self.src.render(*args))
+ finally:
+ src.pool.close()
+
+ def test_prerender_saves_the_region_tiles(self):
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ seen = []
+ n = src.prerender(max_z=6, progress=lambda done, total: seen.append((done, total)))
+ for z in (5, 6):
+ for layer in ("relief", "mesh"):
+ self.assertTrue(src.cache_path(layer, z, *tile_at(self.lat, self.lon, z)).exists(), (layer, z))
+ self.assertFalse(src.cache_path("relief", 6, *tile_at(self.lat + 40, self.lon, 6)).exists())
+ self.assertGreater(n, 0)
+ self.assertEqual(seen[-1], (n, n))
+ self.assertEqual(src.prerender(max_z=6), 0, "saved tiles are not rendered again")
+
+ def _empty_src(self):
+ return T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()),
+ regions_dir=Path(tempfile.mkdtemp()))
+
+ def _add_area(self, src):
+ import refine as RF
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ dst = RF.results_dir(src.regions_dir, src.fingerprint) / d.name
+ shutil.copytree(d, dst)
+ return dst
+
+ def test_a_reload_during_a_render_never_caches_mixed_tiles(self):
+ src = self._empty_src()
+ args = ("relief", 8, *tile_at(self.lat, self.lon, 8))
+ orig, calls = src.render, []
+
+ def render(*a):
+ data = orig(*a) # rendered with the old (no) regions …
+ if not calls:
+ calls.append(1)
+ self._add_area(src)
+ src.reload_regions() # … while a build finished
+ return data
+ src.render = render
+ want = self.src.render(*args)
+ self.assertEqual(src.tile(*args), want)
+ self.assertEqual(src.tile(*args), want, "nor from the memory cache")
+ base = src.cache_root / src.base_tag / "relief" / "8" / str(args[2]) / f"{args[3]}.jpg"
+ self.assertFalse(base.exists(), "no pre-build bytes in the world's cache for a tile the region covers")
+
+ def test_workers_never_render_a_half_built_region_set(self):
+ src = self._empty_src()
+ src.start_workers(1) # forked without regions
+ try:
+ area = self._add_area(src)
+ src.reload_regions() # the server has the region …
+ shutil.rmtree(area) # … a new build has pruned it on disk meanwhile
+ args = ("relief", 8, *tile_at(self.lat, self.lon, 8))
+ self.assertEqual(src.tile(*args), self.src.render(*args))
+ finally:
+ src.pool.close()
+
+ def test_dead_workers_never_hang_tile_requests(self):
+ import os
+ import signal
+ src = self._empty_src()
+ orig = src.render
+ src.render = lambda *a: (time.sleep(1.0), orig(*a))[1] # slow renders (the forked worker inherits this)
+ src.start_workers(1)
+ try:
+ args = [("relief", 9, *tile_at(self.lat, self.lon, 9)), ("relief", 9, *tile_at(self.lat + 10, self.lon, 9))]
+ got = [None, None]
+
+ def run(k):
+ got[k] = src.tile(*args[k])
+ ts = [threading.Thread(target=run, args=(k,), daemon=True) for k in (0, 1)]
+ ts[0].start()
+ time.sleep(0.15) # the first request is inside the worker …
+ ts[1].start()
+ time.sleep(0.15) # … the second waits for it …
+ os.kill(src.pool.procs[0].pid, signal.SIGKILL) # … and the worker dies
+ for t in ts:
+ t.join(30)
+ self.assertEqual(got, [self.plain.render(*a) for a in args], "both requests answered (rendered here)")
+ finally:
+ src.pool.close()
+
+ def test_a_region_change_keeps_the_saved_tiles_of_unchanged_areas(self):
+ import refine as RF
+ from tests.test_refine import square
+ tmp = Path(tempfile.mkdtemp())
+ far_lat = self.lat - 50 if self.lat > 0 else self.lat + 50
+ a, b = square(self.lat, self.lon, 3, "a"), square(far_lat, self.lon + 90, 3, "b")
+ (tmp / "one.json").write_text(json.dumps({"regions": [a]}))
+ (tmp / "two.json").write_text(json.dumps({"regions": [a, b]}))
+ res = tmp / "results"
+ RF.build_areas(self.root, 2, tmp / "one.json", log=lambda s: None, regions_root=res)
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=tmp / "cache", regions_dir=res)
+ first = src.prerender(max_z=6, layers=("relief",))
+ near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6))
+ old_tag = src.tag
+ RF.build_areas(self.root, 2, tmp / "two.json", log=lambda s: None, regions_root=res)
+ src.reload_regions()
+ self.assertNotEqual(src.tag, old_tag)
+ self.assertTrue(src.cache_path(*near_a).exists(), "area a's saved tiles moved to the new region set")
+ self.assertFalse((src.cache_root / old_tag).exists())
+ again = src.prerender(max_z=6, layers=("relief",))
+ self.assertGreater(again, 0, "area b's tiles are new")
+ self.assertLess(again, first + again - 1, "a's are not rendered again")
+ self.assertLessEqual(again, first * 1.5)
+
+ def _two_areas(self):
+ import refine as RF
+ from tests.test_refine import square
+ tmp = Path(tempfile.mkdtemp())
+ far_lat = self.lat - 50 if self.lat > 0 else self.lat + 50
+ a, b = square(self.lat, self.lon, 3, "a"), square(far_lat, self.lon + 90, 3, "b")
+ (tmp / "one.json").write_text(json.dumps({"regions": [a]}))
+ (tmp / "two.json").write_text(json.dumps({"regions": [a, b]}))
+ RF.build_areas(self.root, 2, tmp / "one.json", log=lambda s: None, regions_root=tmp / "results")
+ return tmp
+
+ def _source(self, tmp):
+ return T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=tmp / "cache",
+ regions_dir=tmp / "results")
+
+ def _rekey(self, src):
+ """Area a's content changes (as after a new refine model or config) while its outline stays."""
+ import refine as RF
+ (link,) = RF.built_areas(src.regions_dir, src.fingerprint)
+ old = Path(os.path.realpath(link))
+ new = old.with_name(old.name + "x")
+ shutil.copytree(old, new)
+ os.utime(new / "cells.npz", ns=(time.time_ns(), time.time_ns() + 10 ** 9))
+ link.unlink()
+ link.symlink_to(os.path.relpath(new, link.parent))
+
+ def test_a_region_change_while_stopped_keeps_the_saved_tiles_of_unchanged_areas(self):
+ import refine as RF
+ tmp = self._two_areas()
+ src = self._source(tmp)
+ src.carry_stale() # a server start
+ src.prerender(max_z=6, layers=("relief",))
+ near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6))
+ old_tag = src.tag
+ RF.build_areas(self.root, 2, tmp / "two.json", log=lambda s: None, regions_root=tmp / "results")
+ src = self._source(tmp) # the next start
+ self.assertNotEqual(src.tag, old_tag)
+ src.carry_stale()
+ self.assertTrue(src.cache_path(*near_a).exists(), "area a's saved tiles moved to the new region set")
+ self.assertFalse((src.cache_root / old_tag).exists())
+
+ def test_saved_tiles_of_an_area_whose_content_changed_are_dropped(self):
+ tmp = self._two_areas()
+ src = self._source(tmp)
+ src.carry_stale()
+ src.prerender(max_z=6, layers=("relief",))
+ near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6))
+ self._rekey(src)
+ src.reload_regions()
+ self.assertFalse(src.cache_path(*near_a).exists(), "a live reload drops them")
+ src.carry_stale()
+ src.prerender(max_z=6, layers=("relief",))
+ self._rekey(src)
+ src = self._source(tmp)
+ src.carry_stale()
+ self.assertFalse(src.cache_path(*near_a).exists(), "so does the next start")
+
+ def test_saved_region_tiles_drawn_by_an_older_region_version_are_not_carried_over(self):
+ tmp = self._two_areas()
+ src = self._source(tmp)
+ src.carry_stale()
+ src.prerender(max_z=6, layers=("relief",))
+ near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6))
+ with mock.patch.object(T, "REGION_VERSION", T.REGION_VERSION + 1): # the next start draws refined areas anew
+ src = self._source(tmp)
+ src.carry_stale()
+ self.assertFalse(src.cache_path(*near_a).exists(), "tiles drawn the old way re-render")
+
+ def test_prerendering_never_delays_the_server_exit(self):
+ import subprocess
+ import sys
+ import textwrap
+ code = textwrap.dedent(f"""
+ import sys, tempfile, threading, time, pathlib; sys.path.insert(0, {str(Path(T.__file__).parent)!r})
+ import tiles as T, serve
+ src = T.TileSource(serve.World(pathlib.Path({str(self.root)!r}), 2), seed={self.src.seed},
+ cache_dir=pathlib.Path(tempfile.mkdtemp()))
+ threading.Thread(target=src.prerender, kwargs=dict(max_z=12, threads=2), daemon=True).start()
+ time.sleep(1.0)
+ print("exiting", flush=True) # as after Ctrl-C: main returns
+ """)
+ t = time.time()
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120)
+ self.assertIn("exiting", p.stdout, p.stderr[-500:])
+ self.assertLess(time.time() - t, 20, "the process ends without rendering the whole queue first")
+
+ def test_a_reload_forks_fresh_workers_sharing_the_new_regions(self):
+ src = self._empty_src()
+ src.start_workers(2)
+ try:
+ before = [p.pid for p in src.pool.procs]
+ self._add_area(src)
+ src.reload_regions()
+ after = [p.pid for p in src.pool.procs]
+ self.assertTrue(set(before).isdisjoint(after), "new processes (they share the loaded regions)")
+ self.assertEqual(src.pool.alive, 2)
+ args = ("relief", 8, *tile_at(self.lat, self.lon, 8))
+ self.assertEqual(src.tile(*args), self.src.render(*args))
+ time.sleep(0.5)
+ self.assertFalse(any(os.path.exists(f"/proc/{q}") and open(f"/proc/{q}/stat").read().split()[2] != "Z"
+ for q in before), "the old workers are gone")
+ finally:
+ src.pool.close()
+
+ def test_workers_forked_by_a_short_lived_thread_keep_working(self):
+ src = self._empty_src()
+ src.start_workers(2)
+ try:
+ self._add_area(src)
+ t = threading.Thread(target=src.reload_regions) # as a region build's thread does
+ t.start()
+ t.join()
+ time.sleep(0.5) # the forking thread is gone now
+ self.assertEqual(src.pool.alive, 2)
+ self.assertTrue(all(p.is_alive() for p in src.pool.procs), "workers outlive the thread that forked them")
+ args = ("relief", 8, *tile_at(self.lat, self.lon, 8))
+ data = src.pool.render(src.name, *args, src.regions.fingerprint) # straight to a worker, no fallback
+ self.assertEqual(data, self.src.render(*args))
+ finally:
+ src.pool.close()
+
+ def test_prerender_keeps_to_a_tile_budget_by_zoom(self):
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ full = src.prerender(max_z=6, layers=("relief",))
+ src2 = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ seen = []
+ n = src2.prerender(max_z=6, layers=("relief",), max_tiles=full - 1, progress=lambda d, t: seen.append(t))
+ self.assertLess(n, full, "the deepest zoom that would overrun the budget is left out")
+ self.assertLessEqual(seen[-1], full - 1)
+ self.assertTrue(src2.cache_path("relief", 5, *tile_at(self.lat, self.lon, 5)).exists(), "coarse zooms first")
+
+ def test_no_water_walls_where_a_lake_sits_on_a_cliff(self):
+ import refine as RF
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ tmp = Path(tempfile.mkdtemp())
+ dst = RF.results_dir(tmp, self.src.fingerprint) / d.name
+ shutil.copytree(d, dst)
+ a = dict(np.load(dst / "cells.npz"))
+ halo, xyz = a["halo"].astype(bool), a["g_xyz"]
+ land = ~halo & ~a["ocean"].astype(bool) & ~a["lake"].astype(bool)
+ mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre
+ c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))])
+ a["river"][c] = False # (the coarse test cells are all rivers)
+ nb = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[1:7] # its six neighbours: a cliff below it
+ a["z_surface_m"][nb] = a["z_surface_m"][c] - 1500.0
+ a["lake"][c], a["lake_id"][c] = True, int(a["lake_id"].max()) + 1
+ a["z_filled_m"][c] = a["z_surface_m"][c] + 50.0
+ a["depression_depth_m"][c] = 50.0
+ np.savez(dst / "cells.npz", **a)
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp)
+ p = xyz[c] + np.linspace(0, 1, 400)[:, None] * (xyz[nb[0]] - xyz[c]) # from the lake over the cliff edge
+ p /= np.linalg.norm(p, axis=1, keepdims=True)
+ lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
+ z = src.z_at(lat, lon, 1.0)
+ step = np.linalg.norm(p[1] - p[0]) * src.R
+ self.assertLess(np.abs(np.diff(z)).max(), 50.0 + RF.LAKE_FLOOR_M + 60.0,
+ "the lake's edge stands at most its depth (+ margin, detail) over the drop, not 1.5 km")
+
+ def test_no_dry_pits_inside_a_lake(self):
+ import refine as RF
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ tmp = Path(tempfile.mkdtemp())
+ dst = RF.results_dir(tmp, self.src.fingerprint) / d.name
+ shutil.copytree(d, dst)
+ a = dict(np.load(dst / "cells.npz"))
+ halo, xyz = a["halo"].astype(bool), a["g_xyz"]
+ land = ~halo & ~a["ocean"].astype(bool)
+ mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre
+ c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))])
+ lake = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[:7] # a cell and its ring: one lake
+ bed = float(a["z_surface_m"][c])
+ a["z_surface_m"][lake] = bed
+ a["lake"][lake], a["river"][lake] = True, False
+ a["lake_id"][lake] = int(a["lake_id"].max()) + 1
+ a["z_filled_m"][lake] = bed + 100.0
+ a["depression_depth_m"][lake] = 100.0
+ np.savez(dst / "cells.npz", **a)
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp)
+ rng = np.random.default_rng(3)
+ sp = src.regions.spacing_km / src.R
+ p = xyz[c] + rng.normal(size=(400, 3)) * sp * 0.12 # well inside the lake (its middle
+ p /= np.linalg.norm(p, axis=1, keepdims=True) # cell and ring: 90 km cells here)
+ lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
+ z, water = src.surface_at(lat, lon, 1.0)
+ below = z < bed + 100.0 - 1e-6
+ self.assertFalse((below & ~water).any(), f"{(below & ~water).sum()} dry pits below the lake's surface")
+ np.testing.assert_allclose(z[water], bed + 100.0, atol=1e-6) # (detail may rise as islands)
+
+ def test_no_dry_pits_where_a_lake_bed_deepens_between_its_cells(self):
+ import refine as RF
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ tmp = Path(tempfile.mkdtemp())
+ dst = RF.results_dir(tmp, self.src.fingerprint) / d.name
+ shutil.copytree(d, dst)
+ a = dict(np.load(dst / "cells.npz"))
+ halo, xyz = a["halo"].astype(bool), a["g_xyz"]
+ land = ~halo & ~a["ocean"].astype(bool)
+ mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre
+ c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))])
+ lake = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[:19] # a cell and two rings: one lake
+ bed = float(a["z_surface_m"][c])
+ a["z_surface_m"][lake] = bed
+ a["z_surface_m"][c] = bed - 200.0 # its middle much deeper
+ a["lake"][lake], a["river"][lake] = True, False
+ a["lake_id"][lake] = int(a["lake_id"].max()) + 1
+ a["z_filled_m"][lake] = bed + 100.0
+ a["depression_depth_m"][lake] = 100.0
+ a["depression_depth_m"][c] = 300.0
+ isle = lake[1] # and an island beside it
+ a["lake"][isle], a["z_surface_m"][isle], a["z_filled_m"][isle] = False, bed + 150.0, bed + 150.0
+ np.savez(dst / "cells.npz", **a)
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp)
+ p = np.concatenate([xyz[c] + np.linspace(0.2, 0.9, 100)[:, None] * (xyz[n] - xyz[c]) for n in lake[2:7]])
+ p /= np.linalg.norm(p, axis=1, keepdims=True) # from the deep middle to the ring
+ lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
+ z, water = src.surface_at(lat, lon, 1.0)
+ below = z < bed + 100.0 - 1e-6
+ self.assertFalse((below & ~water).any(), f"{(below & ~water).sum()} dry pits below the lake's surface")
+
+ def test_ground_beside_a_lake_below_its_surface_is_under_water(self):
+ import refine as RF
+ (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint)
+ tmp = Path(tempfile.mkdtemp())
+ dst = RF.results_dir(tmp, self.src.fingerprint) / d.name
+ shutil.copytree(d, dst)
+ a = dict(np.load(dst / "cells.npz"))
+ halo, xyz = a["halo"].astype(bool), a["g_xyz"]
+ land = ~halo & ~a["ocean"].astype(bool)
+ mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre
+ c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))])
+ ring = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[:7]
+ lake, shore = ring[:6], ring[6] # a lake and a dry cell beside it
+ bed = float(a["z_surface_m"][c])
+ a["z_surface_m"][ring] = bed
+ a["lake"][lake], a["river"][ring] = True, False
+ a["lake"][shore] = False
+ a["lake_id"][lake] = int(a["lake_id"].max()) + 1
+ a["z_filled_m"][lake] = bed + 100.0
+ a["depression_depth_m"][lake] = 100.0
+ a["z_surface_m"][shore] = a["z_filled_m"][shore] = bed + 60.0 # 40 m below the lake's surface
+ np.savez(dst / "cells.npz", **a)
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp)
+ p = xyz[c] + np.linspace(0.55, 0.65, 50)[:, None] * (xyz[shore] - xyz[c]) # just past the cells' border
+ p /= np.linalg.norm(p, axis=1, keepdims=True)
+ lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
+ z, water = src.surface_at(lat, lon, 1.0)
+ below = z < bed + 100.0 - 1e-6
+ self.assertFalse((below & ~water).any(), f"{(below & ~water).sum()} dry pits below the lake's surface")
+
+ def test_tiles_away_from_regions_share_the_world_cache(self):
+ far, near = ("relief", 8, *tile_at(self.lat + 20, self.lon, 8)), ("relief", 8, *tile_at(self.lat, self.lon, 8))
+ self.assertIn(self.plain.tag, self.src.cache_path(*far).parts, "a region change keeps the tiles away from it")
+ self.assertIn(self.src.tag, self.src.cache_path(*near).parts)
+
+ def test_a_region_set_lists_the_built_areas_once(self):
+ import refine as RF
+ real, calls = RF.built_areas, []
+ with mock.patch.object(RF, "built_areas", side_effect=lambda *a: calls.append(1) or real(*a)):
+ rs = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R)
+ self.assertEqual(len(calls), 1, "names and fingerprint from one listing: a build finishing between is not mixed")
+ self.assertEqual(rs.fingerprint, RF.regions_fingerprint(self.src.regions_dir, self.src.fingerprint))
+
+ def test_cached_region_set_equals_the_uncached_one(self):
+ import refine as RF
+ fresh = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R, use_cache=False)
+ cached = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R)
+ self.assertIsNotNone(cached.cache)
+ self.assertTrue((cached.cache / "done").exists())
+ self.assertEqual(set(cached.arrays), set(fresh.arrays))
+ for k in fresh.arrays:
+ np.testing.assert_array_equal(np.asarray(cached.arrays[k]), fresh.arrays[k], err_msg=k)
+ for k in ("z_env", "lake_level", "lake_floor"):
+ np.testing.assert_array_equal(getattr(cached, k), getattr(fresh, k), err_msg=k)
+ self.assertEqual(cached.spacing_km, fresh.spacing_km)
+ self.assertEqual(cached.area_names, fresh.area_names)
+ import rivers as RV
+ for k in RV.STATE:
+ np.testing.assert_array_equal(getattr(cached.net, k), getattr(fresh.net, k), err_msg=k)
+
+ def test_low_memory_region_set_spills_to_disk_with_equal_arrays(self):
+ import refine as RF
+ dirs = RF.built_areas(self.src.regions_dir, self.src.fingerprint)
+ want, wmeta = RF._compute_region_set(dirs, self.src.w.legends, self.src.R) # oracle: the in-RAM build
+ with tempfile.TemporaryDirectory() as spill:
+ got, gmeta = RF._compute_region_set(dirs, self.src.w.legends, self.src.R, spill=Path(spill))
+ self.assertEqual(set(got), set(want))
+ self.assertEqual(gmeta, wmeta)
+ for k in want:
+ np.testing.assert_array_equal(np.asarray(got[k]), want[k], err_msg=k)
+ big = [k for k in got if k.startswith("a.")]
+ self.assertTrue(big and all(isinstance(got[k], np.memmap) for k in big), "fields kept on disk")
+ del got
+
+ def test_low_memory_region_set_leaves_no_spill_folder(self):
+ import refine as RF
+ with tempfile.TemporaryDirectory() as d, mock.patch.dict(os.environ, {"WORLDGEN_LOW_MEMORY": "1"}):
+ regions = Path(d) / "regions"
+ shutil.copytree(self.src.regions_dir, regions, symlinks=True)
+ for p in regions.glob("*/serve-*"):
+ shutil.rmtree(p)
+ rs = RF.RegionSet(regions, self.src.fingerprint, self.src.w.legends, self.src.R)
+ self.assertTrue((rs.cache / "done").exists())
+ self.assertEqual(list(regions.rglob(".spill-*")), [])
+
+ def test_cached_tiles_and_meshes_are_byte_identical(self):
+ import refine as RF
+ fresh = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ fresh.regions = RF.RegionSet(fresh.regions_dir, fresh.fingerprint, fresh.w.legends, fresh.R, use_cache=False)
+ fresh.tag = fresh.base_tag + fresh._region_tag(fresh.regions)
+ for z in (6, 8):
+ x, y = tile_at(self.lat, self.lon, z)
+ self.assertEqual(self.src.render("relief", z, x, y), fresh.render("relief", z, x, y), f"relief z{z}")
+ np.testing.assert_array_equal(self.src.mesh(z, x, y), fresh.mesh(z, x, y))
+
+ def test_trees_are_not_built_at_load(self):
+ import refine as RF
+ rs = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R)
+ self.assertIsNone(rs._tree)
+ self.assertIsNone(rs._halo_tree)
+ self.assertEqual(len(rs.area_trees._built), 0)
+ rs.warm()
+ self.assertIsNotNone(rs._tree)
+ self.assertIsNotNone(rs._halo_tree)
+ self.assertIsNotNone(rs.net._tree)
+
+ def test_start_workers_warms_region_trees_before_forking(self):
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()))
+ order = []
+ with mock.patch.object(src.regions, "warm", side_effect=lambda: order.append("warm")), \
+ mock.patch.object(T, "RenderPool", side_effect=lambda s, n: order.append("fork")):
+ src.start_workers(2)
+ self.assertEqual(order, ["warm", "fork"])
+
+ def test_rebuilt_regions_get_a_new_cache_and_the_old_is_pruned(self):
+ import refine as RF
+ own = Path(tempfile.mkdtemp()) / "regions"
+ shutil.copytree(self.src.regions_dir, own, symlinks=True) # the class's areas keep their mtimes
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()),
+ regions_dir=own)
+ old = src.regions.cache
+ area = RF.built_areas(src.regions_dir, src.fingerprint)[0] / "cells.npz"
+ os.utime(area) # a rebuilt area: new mtime → new regions fingerprint
+ src.reload_regions(cleanup=True)
+ self.assertNotEqual(src.regions.cache, old)
+ self.assertTrue(src.regions.cache.exists())
+ self.assertFalse(old.exists())
+ self.assertTrue(self.src.regions.cache.exists(), "the shared fixture's cache is untouched")
+
+ def test_build_areas_keeps_serve_cache_folders(self):
+ import refine as RF
+ import servecache as SC
+ base = RF.results_dir(self.src.regions_dir, self.src.fingerprint)
+ marker = SC.cache_dir(base, "somefp")
+ marker.mkdir(parents=True, exist_ok=True)
+ try:
+ RF.build_areas(self.root, 2, self.root / "places" / "regions.json", log=lambda s: None)
+ self.assertTrue(marker.exists(), "serve caches are pruned by the server, not by builds")
+ finally:
+ shutil.rmtree(marker, ignore_errors=True)
+
+ def test_region_cache_follows_the_code_that_computes_it(self):
+ import refine as RF
+ code = Path(tempfile.mkdtemp()) / "rivers.py"
+ code.write_text("A = 1\n")
+ with mock.patch.object(RF, "CODE_FILES", (code,)):
+ a = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R).cache
+ code.write_text("A = 2\n") # e.g. a river constant changed: the stored net is stale
+ b = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R).cache
+ self.assertNotEqual(a, b)
+ self.assertTrue((b / "done").exists())
+
+ def test_an_empty_region_set_prunes_the_last_cache(self):
+ import refine as RF
+ regions = Path(tempfile.mkdtemp()) / "regions"
+ shutil.copytree(self.src.regions_dir, regions) # (the shared fixture stays as it is)
+ src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()),
+ regions_dir=regions)
+ old = src.regions.cache
+ self.assertTrue((old / "done").exists())
+ for d in RF.built_areas(regions, src.fingerprint): # the last region deleted
+ shutil.rmtree(d)
+ src.reload_regions(cleanup=True)
+ self.assertTrue(src.regions.empty)
+ self.assertFalse(old.exists())
+
+
+class ProfileSurfaceTest(unittest.TestCase):
+ def test_profile_water_matches_the_tiles(self):
+ server = serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")
+ with Running(server) as base:
+ prof = json.loads(request(base + "/api/profile?path=10,-85;10,-55&n=64")[1]) # across the west coast
+ S = prof["samples"]
+ step = S[1]["d_km"] - S[0]["d_km"]
+ z, water = server.tiles.surface_at([x["lat"] for x in S], [x["lon"] for x in S], step / 2)
+ np.testing.assert_allclose([x["z_m"] for x in S], z, atol=0.1)
+ self.assertEqual([x["water"] for x in S], [bool(w) for w in water])
+ self.assertTrue(any(water) and not all(water))
+
+
+class TileEndpointTest(unittest.TestCase):
+ def test_endpoints(self):
+ v = T.VERSION
+ with Running(serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")) as base:
+ meta = json.loads(request(base + "/api/meta")[1])
+ tag = meta["tiles"]["url"].split("/")[2] # v1-<build fingerprint>
+ s1, b1 = request(base + f"/tiles/{tag}/relief/5/40/10.jpg")
+ s2, _ = request(base + f"/tiles/{tag}/height/5/40/10.png")
+ s3, b3 = request(base + f"/tiles/{tag}/mesh/5/40/10.bin")
+ s4 = request(base + f"/tiles/{tag}/mesh/5/40/10.jpg")[0]
+ bad = [request(base + p)[0] for p in (f"/tiles/{tag}/nope/5/1/1.jpg", f"/tiles/{tag}/relief/5/99/1.jpg",
+ f"/tiles/{tag}/relief/17/0/0.jpg", f"/tiles/v{v}-0000000000/relief/5/1/1.jpg", f"/tiles/{tag}/relief/5/1/1.png",
+ f"/tiles/{tag}/height/5/1/1.jpg", f"/tiles/v{v}/relief/5/1/1.jpg")]
+ cell = json.loads(request(base + "/api/cell?lat=10&lon=20")[1])
+ prof = json.loads(request(base + "/api/profile?path=10,10;10,30&n=32")[1])
+ self.assertRegex(meta["tiles"]["url"], rf"^/tiles/v{v}\.{T.DRAW}-[0-9a-f]{{10}}/{{layer}}/{{z}}/{{x}}/{{y}}\.jpg$")
+ self.assertEqual((meta["tiles"]["min_z"], meta["tiles"]["max_z"]), (5, 16))
+ self.assertEqual((s1, Image.open(io.BytesIO(b1)).size), (200, (256, 256)))
+ self.assertEqual(s2, 200)
+ self.assertEqual(bad, [404] * 7)
+ self.assertRegex(meta["tiles"]["mesh_url"], rf"^/tiles/v{v}\.{T.DRAW}-[0-9a-f]{{10}}/mesh/{{z}}/{{x}}/{{y}}\.bin$")
+ self.assertEqual(T.MESH_N, 129, "3D heights: 129 × 129 per tile (author 2026-09-25)")
+ self.assertEqual((s3, len(b3), s4), (200, T.MESH_BYTES, 404))
+ self.assertEqual((T.VERSION, T.DRAW), (6, 5))
+ f = {x["key"]: x for x in cell["fields"]}
+ self.assertEqual(f["z_detail_m"]["label"], "Local elevation (procedural)")
+ self.assertIn("spans", f["z_detail_m"]["alt"])
+ self.assertTrue(all("z_cell_m" in s for s in prof["samples"]))
+
+
+class SharedPoolTest(unittest.TestCase):
+ def test_two_sources_share_one_pool(self):
+ a_root, b_root = built_world(), built_world()
+ a = T.TileSource(serve.World(a_root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()), name="a")
+ b = T.TileSource(serve.World(b_root, 2), seed=8, cache_dir=Path(tempfile.mkdtemp()), name="b")
+ T.join([a, b])
+ self.assertIs(a.peers, b.peers)
+ a.start_workers(1)
+ try:
+ self.assertIs(a.pool, b.pool)
+ args = (6, *tile_at(10.0, 20.0, 6))
+ self.assertEqual(b.pool.render("b", "relief", *args, b.regions.fingerprint), b.render("relief", *args))
+ self.assertEqual(a.pool.render("a", "relief", *args, a.regions.fingerprint), a.render("relief", *args))
+ self.assertNotEqual(a.render("relief", *args), b.render("relief", *args), "seeds differ: two worlds")
+ old = a.pool
+ b.reload_regions()
+ self.assertIsNot(a.pool, old, "a reload forks fresh workers for every peer")
+ self.assertIs(a.pool, b.pool)
+ made = []
+ real = T.RenderPool
+ with mock.patch.object(T, "RenderPool", side_effect=lambda *x: made.append(1) or real(*x)):
+ old = a.pool
+ T.reload_all([a, b])
+ self.assertEqual(len(made), 1, "every era reloaded, then one fork")
+ self.assertIsNot(a.pool, old)
+ self.assertIs(a.pool, b.pool)
+ finally:
+ a.pool.close()
+
+
+class _Hangs:
+ name = "h"
+
+ class regions:
+ fingerprint = "fp"
+
+ def render(self, layer, z, x, y):
+ time.sleep(3600)
+
+
+class PoolTimeoutTest(unittest.TestCase):
+ def test_a_hung_worker_is_stopped_and_the_request_fails(self):
+ pool = T.RenderPool({"h": _Hangs()}, 1)
+ try:
+ with mock.patch.object(T, "RENDER_TIMEOUT_S", 0.5):
+ t = time.time()
+ with self.assertRaisesRegex(RuntimeError, "timed out"):
+ pool.render("h", "relief", 5, 1, 1, "fp")
+ self.assertLess(time.time() - t, 5)
+ self.assertEqual(pool.alive, 0)
+ pool.procs[0].join(timeout=5)
+ self.assertFalse(pool.procs[0].is_alive(), "the stuck process is gone")
+ finally:
+ pool.close()
+
+
+class EraShareTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import refine as RF
+ from mapgen import config as C
+ from mapgen import eras as ER
+ from tests.test_refine import era_world, square, write_regions
+ cls.root, cls.far, cls.cut, _ = era_world()
+ tmp = Path(tempfile.mkdtemp())
+ reg = write_regions(tmp / "regions.json", square(*cls.far, 2, "far"), square(*cls.cut, 2, "cut"))
+ cls.rdir = tmp / "res"
+ RF.build_areas(cls.root, 2, reg, log=lambda s: None, regions_root=cls.rdir)
+ seed = int(C.load(cls.root)[0]["build"]["seed"]) # the areas were built for the world's own seed
+ cls.base = T.TileSource(serve.World(cls.root, 2), seed=seed, cache_dir=tmp / "tb", regions_dir=cls.rdir)
+ cls.era = T.TileSource(serve.World(cls.root, 2, out=ER.era_dir(cls.root, 2, "after")), seed=seed,
+ cache_dir=tmp / "te", regions_dir=cls.rdir, name="after", share=cls.base)
+ T.join([cls.base, cls.era])
+
+ def test_area_keys_name_the_content(self):
+ rs_b, rs_e = self.base.regions, self.era.regions
+ self.assertEqual(sorted(rs_b.area_keys), sorted(rs_b.area_names))
+ same = [h for h in rs_e.area_names if rs_e.area_keys[h] == rs_b.area_keys.get(h)]
+ self.assertEqual(len(same), 1, "the far area is shared, the cut one is the era's own")
+
+ def test_tiles_away_from_the_era_are_the_bases(self):
+ args = (7, *tile_at(*self.far, 7))
+ self.assertTrue(self.era.shared(*args))
+ self.assertEqual(self.era.tile("relief", *args), self.base.tile("relief", *args))
+ self.assertEqual(self.era.cache_path("relief", *args), self.base.cache_path("relief", *args))
+ self.assertFalse(any((self.era.cache_root).rglob("*.jpg")), "nothing saved twice")
+
+ def test_a_shared_tile_keeps_the_render_limit(self):
+ import threading
+ args = (8, *tile_at(*self.far, 8))
+ self.assertTrue(self.era.shared(*args))
+ gate = threading.BoundedSemaphore(1)
+ gate.acquire() # no slot free
+ with self.assertRaises(T.Busy):
+ self.era.tile("seafloor", *args, remember=False, gate=gate)
+
+ def test_tiles_near_the_mask_are_the_eras_own(self):
+ args = (7, *tile_at(*self.cut, 7))
+ self.assertFalse(self.era.shared(*args))
+ self.assertNotEqual(self.era.tile("height", *args), self.base.tile("height", *args))
+
+ def test_the_mask_edge_alone_decides_without_refined_areas(self):
+ from mapgen import eras as ER
+ from mapgen.sphere import gc_dist_km
+ tmp = Path(tempfile.mkdtemp())
+ era = T.TileSource(serve.World(self.root, 2, out=ER.era_dir(self.root, 2, "after")), seed=self.base.seed,
+ cache_dir=tmp / "te", regions_dir=tmp / "none", name="after", share=self.base)
+ with np.load(ER.era_dir(self.root, 2, "after") / "cells.npz") as a:
+ xyz, m = np.asarray(a["g_xyz"], np.float64), a["era_mask"].astype(bool)
+ z, span = 9, 180.0 / 2 ** 9
+ near = far = None
+ for k in range(1, 4000):
+ lon = self.cut[1] + k * span
+ x, y = tile_at(self.cut[0], lon, z)
+ c = latlon_to_xyz(90 - (y + 0.5) * span, -180 + (x + 0.5) * span)
+ d = gc_dist_km(xyz, c, era.R)
+ to_mask = float(d[m].min())
+ if near is None and not m[int(np.argmin(d))] and to_mask < 2 * era._spacing_km:
+ near = (x, y)
+ if to_mask > 2 * era._spacing_km + T.CARRY_KM + 2 * span * np.pi / 180 * era.R:
+ far = (x, y)
+ break
+ self.assertIsNotNone(near)
+ self.assertIsNotNone(far)
+ self.assertFalse(era.shared(z, *near), "just outside the mask: the era's own")
+ self.assertTrue(era.shared(z, *far), "beyond the mask's reach: the base's")
+
+ def test_the_base_shares_nothing(self):
+ self.assertFalse(self.base.shared(7, *tile_at(*self.far, 7)))
+
+
+class SeaFloorTileTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.root = built_world()
+ cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+
+ def test_depth_shading_darkens_with_depth(self):
+ z = np.tile(np.linspace(-6000.0, -10.0, 64), (64, 1))
+ rgb = T.seafloor_rgb(z, np.full(z.shape, 0.7), np.full(z.shape, 3), np.zeros(z.shape, bool)).astype(float)
+ self.assertLess(rgb[:, :8].mean(), rgb[:, -8:].mean(), "deep is darker")
+
+ def test_type_tints_the_sea_and_land_keeps_relief(self):
+ z = np.full((8, 8), -3000.0)
+ land = np.zeros((8, 8), bool)
+ land[:, :4] = True
+ a = T.seafloor_rgb(np.where(land, 500.0, z), np.full(z.shape, 0.7), np.full(z.shape, 3), land)
+ b = T.seafloor_rgb(np.where(land, 500.0, z), np.full(z.shape, 0.7), np.full(z.shape, 7), land)
+ self.assertFalse(np.array_equal(a[:, 4:], b[:, 4:]), "clay vs vent field")
+ np.testing.assert_array_equal(a[:, :4], b[:, :4])
+
+ def test_seafloor_tile(self):
+ self.assertIn("seafloor", T.LAYERS)
+ args = (5, *tile_at(-30.0, -150.0, 5))
+ im = Image.open(io.BytesIO(self.src.tile("seafloor", *args)))
+ self.assertEqual(im.size, (256, 256))
+ f = self.src.fields(*args, 0, ("cat",))
+ self.assertIn("seabed_type", f)
+
+ def test_world_holds_the_new_fields(self):
+ for k in ("seabed_type", "bottom_temp_c", "pressure_bar", "fire_reactivity", "o2_fraction"):
+ self.assertIn(k, self.src.w.arrays, k)
+
+ def test_new_data_layers_render(self):
+ args = (6, *tile_at(10.0, 20.0, 6))
+ for layer in ("seabed", "minerals", "bottom_temp", "sediment", "vent_potential", "pressure", "fire"):
+ self.assertIn(layer, T.LAYERS, layer)
+ self.assertEqual(Image.open(io.BytesIO(self.src.tile(layer, *args))).size, (256, 256), layer)
+
+ def test_ocean_layers(self):
+ for layer in ("currents", "sst", "productivity"):
+ self.assertIn(layer, T.LAYERS, layer)
+ self.assertEqual(T.CONT["currents"], "current_speed")
+
+
+class LandColourTest(unittest.TestCase):
+ """Land colour = terrain shading + a vegetation mosaic (woods vs open ground; author 2026-10-03, spike E)."""
+ @classmethod
+ def setUpClass(cls):
+ cls.root = built_world()
+ cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+ a = cls.src.w.arrays
+ i = int(np.flatnonzero(~np.asarray(a["ocean"]).astype(bool))[0])
+ q = np.asarray(a["g_xyz"][i], dtype=float)
+ cls.land = float(np.degrees(np.arcsin(q[2] / np.linalg.norm(q)))), float(np.degrees(np.arctan2(q[1], q[0])))
+ names = list(cls.src.w.legends["holdridge"])
+ cls.zone = {n: names.index(n) for n in names}
+
+ def synthetic(self, z, x, y, zone, height=None, water=None):
+ B = T.LAND_BORDER
+ f = dict(self.src.fields(z, x, y, B, ("z",), valleys=False))
+ n = f["lat"].shape[0]
+ f["z"] = f["base"] + (0.0 if height is None else height) # height: above the smooth base surface
+ f["water"] = np.zeros((n, n), bool) if water is None else water
+ f["lake"] = np.zeros((n, n), np.int64)
+ f["river_draw"] = np.zeros((n, n), bool)
+ f["holdridge"] = np.full((n, n), self.zone[zone])
+ f["ground"] = np.zeros((n, n), np.int64)
+ return f
+
+ def wood_share(self, zone, z=9):
+ x0, y0 = tile_at(*self.land, z)
+ return float(np.mean([self.src._veg(self.synthetic(z, x0 + i, y0 + j, zone), T.LAND_BORDER)["wood"].mean()
+ for i in range(3) for j in range(3)]))
+
+ def test_wide_border_keeps_the_interior(self): # a wider border must not change what the tile itself holds
+ z, x, y = 10, *tile_at(*self.land, 10)
+ a = self.src.fields(z, x, y, 1, ("z", "water", "cat"))
+ b = self.src.fields(z, x, y, 9, ("z", "water", "cat"))
+ for k in ("z", "water", "holdridge", "river_draw", "lake"):
+ np.testing.assert_array_equal(a[k], b[k][8:-8, 8:-8], k)
+
+ def test_border_is_the_neighbours_own_pixels(self): # a wide border holds what the next tile draws there
+ B = T.LAND_BORDER
+ z, x, y = 10, *tile_at(*self.land, 10)
+ f = self.src.fields(z, x, y, B, ("z", "water", "cat"))
+ west = self.src.fields(z, x - 1, y, 0, ("z", "water", "cat"))
+ np.testing.assert_allclose(f["z"][B:-B, :B], west["z"][:, -B:], atol=1e-6)
+ np.testing.assert_array_equal(f["holdridge"][B:-B, :B], west["holdridge"][:, -B:])
+
+ def test_colour_does_not_depend_on_the_border(self): # seamless: nothing reaches past LAND_BORDER
+ B = T.LAND_BORDER
+ z, x, y = 10, *tile_at(*self.land, 10)
+ a = self.src._veg(self.src.fields(z, x, y, B, ("z", "water", "cat")), B)
+ b = self.src._veg(self.src.fields(z, x, y, 2 * B, ("z", "water", "cat")), 2 * B)
+ for k in ("wood", "bright", "tint"):
+ np.testing.assert_allclose(a[k], b[k], atol=1e-9, err_msg=k)
+
+ def test_cover_by_zone(self):
+ c = T.veg_cover(list(self.src.w.legends["holdridge"]))
+ z = self.zone
+ self.assertEqual(c[z["polar desert"]], 0.0)
+ self.assertLess(c[z["tropical desert"]], c[z["tropical thorn woodland"]])
+ self.assertLess(c[z["tropical thorn woodland"]], c[z["tropical dry forest"]])
+ self.assertLess(c[z["tropical dry forest"]], c[z["tropical rain forest"]])
+ self.assertLess(c[z["cool temperate steppe"]], c[z["cool temperate moist forest"]])
+ self.assertLess(c[z["subpolar dry tundra"]], c[z["subpolar wet tundra"]])
+
+ def test_wetter_zones_are_more_wooded(self):
+ rain, dry, desert = (self.wood_share(n) for n in ("tropical rain forest", "tropical dry forest", "tropical desert"))
+ self.assertGreater(rain, 0.6)
+ self.assertLess(desert, 0.12)
+ self.assertLess(desert, dry)
+ self.assertLess(dry, rain)
+
+ def test_far_out_the_mosaic_is_the_zone_mean(self): # patches of a few px read as noise: none at z4
+ f = self.synthetic(4, *tile_at(*self.land, 4), "tropical dry forest")
+ c = T.veg_cover(list(self.src.w.legends["holdridge"]))[self.zone["tropical dry forest"]]
+ np.testing.assert_allclose(self.src._veg(f, T.LAND_BORDER)["wood"], c, atol=1e-12)
+
+ def test_salt_flats_bare(self):
+ f = self.synthetic(9, *tile_at(*self.land, 9), "tropical rain forest")
+ f["ground"][:] = list(self.src.w.legends["ground"]).index("salt flat")
+ self.assertEqual(float(self.src._veg(f, T.LAND_BORDER)["wood"].max()), 0.0)
+
+ def test_woods_in_hollows_not_on_crests(self): # the same pixels, flat vs sine ridges (the patches cancel)
+ z, (x, y) = 11, tile_at(*self.land, 11)
+ n, B = T.TILE + 2 * T.LAND_BORDER, T.LAND_BORDER
+ s = np.sin(2 * np.pi * np.arange(n) / 48.0)
+ zone = "warm temperate dry forest" # cover ½: room to gain and to lose
+ d = np.hstack([self.src._veg(self.synthetic(z, x + i, y, zone, height=np.tile(120.0 * s, (n, 1))), B)["wood"]
+ - self.src._veg(self.synthetic(z, x + i, y, zone), B)["wood"] for i in range(3)])
+ s = np.tile(s[B:-B], 3)
+ self.assertLess(d[:, s > 0.97].mean(), -0.03, "fewer woods on crests") # flat tops and bottoms: no slope term
+ self.assertGreater(d[:, s < -0.97].mean(), 0.03, "more in hollows")
+
+ def test_woods_along_water(self):
+ z, x, y = 12, *tile_at(*self.land, 12)
+ n, B = T.TILE + 2 * T.LAND_BORDER, T.LAND_BORDER
+ wet = np.zeros((n, n), bool)
+ wet[:, n // 2] = True
+ zone = "warm temperate dry forest"
+ d = self.src._veg(self.synthetic(z, x, y, zone, water=wet), B)["wood"] - \
+ self.src._veg(self.synthetic(z, x, y, zone), B)["wood"]
+ mid = T.TILE // 2
+ self.assertGreater(d[:, np.r_[mid - 3:mid, mid + 1:mid + 4]].mean(), 0.15, "woods along the water")
+ self.assertAlmostEqual(float(np.abs(d[:, :mid - 20]).max()), 0.0, 9, msg="none far away")
+
+ def test_relief_tile_renders_and_repeats(self):
+ z, x, y = 9, *tile_at(*self.land, 9)
+ a, b = self.src.rgb("relief", z, x, y), self.src.rgb("relief", z, x, y)
+ self.assertEqual(a.shape, (T.TILE, T.TILE, 3))
+ np.testing.assert_array_equal(a, b)
+
+
+class GullyTest(unittest.TestCase):
+ """Relief detail with erosion gullies on slopes (author 2026-10-03: spike C; full rebuild accepted)."""
+ @classmethod
+ def setUpClass(cls):
+ cls.root = built_world()
+ cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()))
+
+ def line(self, lat0, lon0, bearing, km, n=400): # points along a straight east (90°) or north (0°) line
+ d = np.linspace(0.0, km, n) / self.src.R
+ if bearing == 90:
+ return np.full(n, lat0), lon0 + np.degrees(d) / np.cos(np.radians(lat0))
+ return lat0 + np.degrees(d), np.full(n, lon0)
+
+ def detail(self, lat, lon, px_km, slope): # on a plane rising east at `slope` m/m, landform amplitude 450 m
+ xyz = latlon_to_xyz(lat, lon)
+ base = lambda la, lo: np.radians(lo) * self.src.R * 1000.0 * np.cos(np.radians(10.0)) * slope
+ n = len(lat)
+ return self.src._detail(xyz, px_km, np.full(n, 450.0), np.full(n, 0.5), lat, lon, base=base)
+
+ def test_gullies_run_downhill(self): # the first (4 km) octave alone (px 0.6 km): stripes along the slope
+ px = 0.6 # (finer octaves follow the gully walls: that is the branching)
+ down = self.detail(*self.line(10.0, 20.0, 90, 40.0), px, 0.4)
+ across = self.detail(*self.line(10.0, 20.0, 0, 40.0), px, 0.4)
+ self.assertGreater(np.std(np.diff(across)), 2.0 * np.std(np.diff(down)))
+
+ def test_flat_ground_keeps_its_detail(self): # no slope: (almost) the plain fractal detail
+ lat, lon = self.line(10.0, 20.0, 0, 3.0)
+ xyz = latlon_to_xyz(lat, lon)
+ n = len(lat)
+ flat = self.src._detail(xyz, 0.03, np.full(n, 25.0), np.full(n, 0.0), lat, lon, base=lambda la, lo: 0.0 * la)
+ plain = 25.0 * self.src._octaves(xyz, 0.03, T.TOP_KM, T.MIN_KM, T.GAIN, self.src.seed + 7001, np.zeros(n))
+ self.assertLess(np.abs(flat - plain).max(), 0.1 * 25.0) # ≤ 10 % of the landform amplitude
+
+ def test_far_out_no_gullies(self): # gullies need ≥ 4 px per wavelength: none at z6
+ lat, lon = self.line(10.0, 20.0, 0, 300.0)
+ xyz, n = latlon_to_xyz(lat, lon), len(lat)
+ px = 2.0
+ d = self.detail(lat, lon, px, 0.4)
+ plain = 450.0 * self.src._octaves(xyz, px, T.TOP_KM, T.MIN_KM, T.GAIN, self.src.seed + 7001, np.full(n, 0.5))
+ np.testing.assert_array_equal(d, plain)
+
+ def test_heights_agree_everywhere(self): # tiles, profile/3D points and the refinement start share one detail
+ z, x, y = 11, *tile_at(10.0, 20.0, 11)
+ f = self.src.fields(z, x, y, 0, ("z",), valleys=False)
+ lat, lon = f["lat"][::37, ::41].ravel(), f["lon"][::37, ::41].ravel()
+ ra = self.src.relief_at(lat, lon, f["px_km"])
+ xyz = latlon_to_xyz(lat, lon)
+ amp, ridge, _ = self.src._amp(xyz)
+ np.testing.assert_allclose(ra - self.src.bilinear(self.src.raster("elevation"), lat, lon),
+ self.src._detail(xyz, f["px_km"], amp, ridge, lat, lon), atol=1e-9)
+
+ def test_compiled_stripes_match_numpy(self):
+ if T._phacelle_jit is None:
+ self.skipTest("numba not installed")
+ rng = np.random.default_rng(3)
+ p = rng.normal(size=(5000, 3))
+ p /= np.linalg.norm(p, axis=1, keepdims=True)
+ e, n = T.east_north(p)
+ ang = rng.uniform(0, 2 * np.pi, 5000)[:, None]
+ a = np.cos(ang) * e + np.sin(ang) * n
+ jit = self.src._phacelle(p, a, 0.5, 123)
+ with mock.patch.object(T, "_phacelle_jit", None):
+ ref = self.src._phacelle(p, a, 0.5, 123)
+ np.testing.assert_allclose(jit[0], ref[0], atol=1e-9)
+ np.testing.assert_allclose(jit[1], ref[1], atol=1e-9)
diff --git a/tests/test_viewer_fixtures.py b/tests/test_viewer_fixtures.py
new file mode 100644
index 0000000..5c0b88b
--- /dev/null
+++ b/tests/test_viewer_fixtures.py
@@ -0,0 +1,23 @@
+import json
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+from mapgen import projections as PJ
+
+FIX = Path(__file__).resolve().parents[1] / "viewer" / "tests" / "fixtures" / "projections.json"
+POINTS = [(0.0, 0.0), (30.0, 120.0), (-60.0, -170.0), (89.0, 45.0), (10.0, -179.0), (-45.5, 33.3)]
+
+
+class ViewerFixtureTest(unittest.TestCase):
+ def test_write_projection_fixture(self):
+ lat = np.array([p[0] for p in POINTS])
+ lon = np.array([p[1] for p in POINTS])
+ x, y = PJ.equal_earth_forward(lat, lon)
+ data = {"ee_xmax": float(PJ.EE_XMAX), "ee_ymax": float(PJ.EE_YMAX),
+ "equal_earth": [{"lat": float(a), "lon": float(b), "x": float(c), "y": float(d)}
+ for a, b, c, d in zip(lat, lon, x, y)]}
+ FIX.parent.mkdir(parents=True, exist_ok=True)
+ FIX.write_text(json.dumps(data, indent=1))
+ self.assertEqual(len(json.loads(FIX.read_text())["equal_earth"]), len(POINTS))