import hashlib import json from pathlib import Path import pytest from PIL import Image import overview from leonida.coords import SLICE def _ring(x0, z0, x1, z1): return [[x0, z0], [x1, z0], [x1, z1], [x0, z1], [x0, z0]] def _fixture(tmp_path: Path) -> Path: """Ein einziger Chunk A1 (Nordwest): Land, eine Straße, ein Gebäude, eine Palme. Chunk-lokale Koordinaten von chunk_0_0 fallen bei scale 1 genau auf die Kartenpixel, weil der Chunk-Ursprung die Nordwest-Ecke des Ausschnitts ist. """ chunk = { "chunk": [0, 0], "origin": [SLICE["x0"], -SLICE["y1"]], "land": [[_ring(0, 0, 250, 250)]], "surfaces": [], "carriageway": [{"poly": [_ring(100, 0, 120, 250)], "klasse": "hauptstrasse"}], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [], "buildings": [{"poly": [_ring(20, 20, 60, 60)], "height": 12.0, "source": "district", "speculative": False}], "palms": [[200.0, 200.0]], } (tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk)) (tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n') return tmp_path def test_map_picture_has_one_pixel_per_metre_and_no_palms(tmp_path): src = tmp_path / "chunks" src.mkdir() _fixture(src) dst = tmp_path / "out" overview.render_map(src, dst) picture = Image.open(dst / "map.png") assert picture.size == (3000, 3000) assert picture.getpixel((110, 125)) == overview.ROAD assert picture.getpixel((40, 40)) == overview._building_shade(12.0) assert picture.getpixel((200, 200)) == overview.LAND # Palme wird nicht gezeichnet assert picture.getpixel((1500, 1500)) == overview.WATER def test_walkable_mask_blocks_buildings_and_water(tmp_path): src = tmp_path / "chunks" src.mkdir() _fixture(src) dst = tmp_path / "out" overview.render_map(src, dst) mask = Image.open(dst / "map-walkable.png") assert mask.mode == "L" assert mask.size == (750, 750) assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m assert mask.getpixel((27, 31)) == overview.BLOCKED # Fahrbahn bei 110 m / 125 m: sperrt jetzt aktiv, auch auf Land assert mask.getpixel((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser def test_map_json_carries_scale_slice_and_manifest_fingerprint(tmp_path): src = tmp_path / "chunks" src.mkdir() _fixture(src) dst = tmp_path / "out" overview.render_map(src, dst) info = json.loads((dst / "map.json").read_text()) assert info["scale"] == 1.0 assert info["walkable_scale"] == 0.25 assert info["slice"] == SLICE assert info["manifest_sha256"] == hashlib.sha256((src / "manifest.json").read_bytes()).hexdigest() def test_debug_overview_still_draws_palms(tmp_path): src = tmp_path / "chunks" src.mkdir() _fixture(src) dst = tmp_path / "debug.png" stats = overview.render(src, dst) assert stats["palmen"] == 1 assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m def test_walkable_mask_counts_only_ground_with_collision(tmp_path): """Ein Belag traegt weiterhin nicht, ganz ohne Landplatte im Chunk. Fahrbahn und Restflaeche sperren aktiv (siehe test_walkable_mask_blocks_buildings_and_water fuer denselben Befund MIT Landplatte); eine Bruecke fuer sich traegt jetzt nicht mehr - ihr begehbarer Streifen liegt im Gehweg, der die Landplatte an der Stelle ersetzt und hier zeigt, dass er das auch ohne sie tut. """ src = tmp_path / "chunks" src.mkdir() chunk = { "chunk": [0, 0], "origin": [SLICE["x0"], -SLICE["y1"]], "land": [], "surfaces": [{"poly": [_ring(0, 0, 40, 40)], "kind": "sand"}], "curb": [], "edges": [], "markings": [], "carriageway": [{"poly": [_ring(100, 0, 120, 40)], "klasse": "wohnstrasse"}], "lots": [{"poly": [_ring(140, 0, 160, 40)]}], "bridges": [{"poly": [_ring(180, 0, 200, 40)]}], "sidewalk": [{"poly": [_ring(220, 0, 240, 40)]}], "buildings": [], "palms": [], } (src / "chunk_0_0.json").write_text(json.dumps(chunk)) (src / "manifest.json").write_text('{"slice": "vice-a"}\n') dst = tmp_path / "out" overview.render_map(src, dst) mask = Image.open(dst / "map-walkable.png") assert mask.getpixel((5, 5)) == overview.BLOCKED # Sand ohne Land bei 20 m / 20 m: traegt nicht assert mask.getpixel((27, 5)) == overview.BLOCKED # Fahrbahn ohne Land bei 108 m / 20 m: sperrt aktiv assert mask.getpixel((37, 5)) == overview.BLOCKED # Restflaeche ohne Land bei 148 m / 20 m: sperrt aktiv assert mask.getpixel((42, 5)) == overview.BLOCKED # Wasser bei 168 m / 20 m assert mask.getpixel((47, 5)) == overview.BLOCKED # Bruecke ohne Land bei 188 m / 20 m: traegt nicht mehr assert mask.getpixel((57, 5)) == overview.WALKABLE # Gehweg bei 228 m / 20 m: traegt jetzt auch ohne Land def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path): """Ein 4-m-Pixel, den Wasser oder ein Gebäude auch nur anschneidet, ist kein Landeplatz.""" src = tmp_path / "chunks" src.mkdir() chunk = { "chunk": [0, 0], "origin": [SLICE["x0"], -SLICE["y1"]], "land": [[_ring(0, 0, 102, 250)]], # Küste bei x = 102 m, mitten in Pixel 25 "surfaces": [], "carriageway": [], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [], "buildings": [{"poly": [_ring(38, 40, 50, 50)], "height": 8.0, "source": "district", "speculative": False}], "palms": [], } (src / "chunk_0_0.json").write_text(json.dumps(chunk)) (src / "manifest.json").write_text('{"slice": "vice-a"}\n') dst = tmp_path / "out" overview.render_map(src, dst) mask = Image.open(dst / "map-walkable.png") assert mask.getpixel((24, 30)) == overview.WALKABLE # 96..100 m: ganz Land assert mask.getpixel((25, 30)) == overview.BLOCKED # 100..104 m: halb Wasser assert mask.getpixel((9, 11)) == overview.BLOCKED # 36..40 m: das Gebäude beginnt bei 38 m assert mask.getpixel((8, 11)) == overview.WALKABLE # 32..36 m: frei @pytest.mark.skip(reason="public/data/vice-a ist bis Task 16 im alten Chunk-Format; siehe Docstring") def test_committed_map_files_match_the_committed_chunks(tmp_path): """Kartenbild, Maske und map.json müssen aus genau den ausgelieferten Chunks stammen. Ändert ein Pipeline-Lauf die Chunks, ohne dass overview.py läuft, zeigte die Minimap eine alte Welt und landete nach einer alten Maske. Die Laufzeitprüfung über manifest_sha256 bemerkt das erst im Browser, dieser Test schon beim Testlauf. Übersprungen bis Task 16: render_map() liest seit dieser Aufgabe (15) nur noch carriageway, sidewalk und lots statt roads und centerlines, aber public/data/vice-a bleibt laut Plan (Ruling zu Task 12 in docs/superpowers/sdd/2026-09-16-strassen/progress.md) bis Task 16 im alten Format — der Pipeline-Lauf in Task 12 schreibt die Daten nur zur Messung und stellt sie danach per git checkout wieder her. Bis Task 16 die Welt neu erzeugt und committet, wirft dieser Test einen KeyError statt eines aussagekräftigen Fehlschlags; Task 16 muss dieses Skip mit den neuen Daten zusammen entfernen. """ shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a" overview.render_map(shipped, tmp_path) for name in ("map.png", "map-walkable.png"): fresh, committed = Image.open(tmp_path / name), Image.open(shipped / name) assert (fresh.size, fresh.mode) == (committed.size, committed.mode), name assert fresh.tobytes() == committed.tobytes(), f"{name} ist veraltet: cd tools/extract && uv run python overview.py" assert json.loads((tmp_path / "map.json").read_text()) == json.loads((shipped / "map.json").read_text()) def test_map_picture_draws_paths_and_the_new_surface_kinds(tmp_path): src = tmp_path / "chunks" src.mkdir() chunk = { "chunk": [0, 0], "origin": [SLICE["x0"], -SLICE["y1"]], "land": [[_ring(0, 0, 250, 250)]], "surfaces": [{"poly": [_ring(0, 0, 40, 40)], "kind": "field"}, {"poly": [_ring(50, 0, 90, 40)], "kind": "court"}, {"poly": [_ring(0, 50, 40, 90)], "kind": "pool"}, {"poly": [_ring(50, 50, 90, 90)], "kind": "parking"}], "carriageway": [], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [], "buildings": [], "palms": [], "paths": [{"poly": [_ring(100, 0, 104, 250)]}], } (src / "chunk_0_0.json").write_text(json.dumps(chunk)) (src / "manifest.json").write_text('{"slice": "vice-a"}\n') dst = tmp_path / "out" overview.render_map(src, dst) picture = Image.open(dst / "map.png") assert picture.getpixel((20, 20)) == overview.SURFACE["field"] assert picture.getpixel((70, 20)) == overview.SURFACE["court"] assert picture.getpixel((20, 70)) == overview.SURFACE["pool"] assert picture.getpixel((70, 70)) == overview.ROAD # Parkplatz in Straßenfarbe assert picture.getpixel((102, 125)) == overview.PATH assert Image.open(dst / "map-walkable.png").getpixel((25, 31)) == overview.WALKABLE # Weg auf Land def _strassen_fixture(tmp_path: Path) -> Path: """Chunk A1 mit den Schichten aus Teil 2. Aufbau wie _fixture() oben in dieser Datei: Bei scale 1 fallen die chunk-lokalen Koordinaten von chunk_0_0 genau auf die Kartenpixel. Der Gehweg ist hier 12 statt 3 m breit (anders als im Planentwurf): _draw() zeichnet Fuellung und Umriss in derselben Farbe (Kommentar dort erklaert, warum), damit angrenzende Flaechen keine Lueckenpixel lassen - an der gemeinsamen Kante mit der Fahrbahn bei x=112 gewinnt dadurch je nach Zeichenreihenfolge die eine oder die andere Flaeche die Grenzspalte. Bei 4 m je Maskenpixel zaehlt eine Zelle nur als frei, wenn alle 16 Meterzellen frei sind; ein 3 m breiter Streifen ist schmaler als eine Maskenzelle und kann darum nie eine ganz umschliessen, unabhaengig von der Rundung. """ chunk = { "chunk": [0, 0], "origin": [SLICE["x0"], -SLICE["y1"]], "land": [[_ring(0, 0, 250, 250)]], "surfaces": [], "buildings": [], "palms": [], "paths": [], "edges": [], "curb": [], "bridges": [], "lots": [{"poly": [_ring(10, 150, 60, 200)]}], "carriageway": [{"poly": [_ring(100, 0, 112, 250)], "klasse": "hauptstrasse"}], "sidewalk": [{"poly": [_ring(112, 0, 124, 250)]}], "markings": [{"poly": [_ring(105, 0, 105.1, 250)], "art": "gelb_doppelt"}], } (tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk)) (tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n') return tmp_path def test_karte_zeichnet_fahrbahn_gehweg_und_restflaeche(tmp_path): src = tmp_path / "chunks" src.mkdir() _strassen_fixture(src) dst = tmp_path / "out" dst.mkdir() overview.render_map(src, dst) bild = Image.open(dst / "map.png").convert("RGB") assert bild.getpixel((105, 10)) == overview.ROAD, "die Fahrbahn" assert bild.getpixel((113, 10)) == overview.SIDEWALK, "der Gehweg daneben" assert bild.getpixel((30, 170)) == overview.ROAD, "die Restflaeche im Strassenton" def test_markierung_wird_nicht_gezeichnet(tmp_path): """Bei 1 px je 3 m waere eine 10-cm-Linie ein Drittel Pixel und damit nur Rauschen.""" src = tmp_path / "chunks" src.mkdir() _strassen_fixture(src) dst = tmp_path / "out" dst.mkdir() overview.render_map(src, dst) bild = Image.open(dst / "map.png").convert("RGB") # Genau auf der Markierung: trotzdem Fahrbahnfarbe, kein Gelb. assert bild.getpixel((105, 120)) == overview.ROAD def test_gehweg_ist_begehbar_die_fahrbahn_nicht(tmp_path): src = tmp_path / "chunks" src.mkdir() _strassen_fixture(src) dst = tmp_path / "out" dst.mkdir() overview.render_map(src, dst) maske = Image.open(dst / "map-walkable.png").convert("L") step = round(1 / overview.WALKABLE_SCALE) # x=117 statt 113: 4 m Abstand zu jeder Kante des jetzt 12 m breiten Gehwegs, genau das Muster # der Fahrbahnprobe unten (x=105, 4 m Abstand zu deren Kanten 100/112) - keine Grenzzelle. assert maske.getpixel((117 // step, 10 // step)) == overview.WALKABLE, "Gehweg" assert maske.getpixel((105 // step, 10 // step)) == overview.BLOCKED, "Fahrbahn"