emit.py normalisiert jede ausgegebene Fläche und sortiert die Listen nach der Ausgabestruktur selbst. unary_union() in apply_street() gab das kartenweite Straßenpolygon mit anderem Ringstartpunkt und an anderer Listenstelle zurück und änderte damit 88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte (symmetrische Differenz 0,00 m²). Belagsarten und Mittellinien behalten ihre Reihenfolge: overview.py malt die Beläge in Listenreihenfolge übereinander, und vergeLampPoints() hängt an Reihenfolge und Laufrichtung der Mittellinien — Sortieren verschöbe dort 1.848 von 3.921 Laternen auf die andere Straßenseite. check_corners() misst gegen den Straßenrand statt gegen die Straßenfläche. Alle vier Ecken liegen in der Fahrbahn, ihr Flächenabstand ist 0,000 m; die alte Prüfung schlug deshalb nur an, wenn eine Straße sich zurückzieht. Eine nach innen verbreiterte Straße hätte apply_park() still auf das veraltete Viereck zurückgeschnitten. Die Parkwege werden am Parkviereck abgeschnitten — die runden Kappen von LineString.buffer() lagen mit 95,96 m² darüber hinaus, rund 41 m² davon sichtbar als Betonzungen auf den umgebenden Belägen. load_elements() prüft den OSM-Abzug so streng wie die Parkdatei, mit Dateiname statt nacktem KeyError. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
6.5 KiB
Python
124 lines
6.5 KiB
Python
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from shapely.geometry import LineString, Point, Polygon, box
|
|
|
|
from leonida.heights import PALM_CLEARANCE
|
|
from leonida.park import ParkError, ParkLayout, World, apply_flamingo_park, apply_park, check_corners, load_park
|
|
|
|
DATA = Path(__file__).resolve().parents[1] / "data"
|
|
|
|
QUAD = box(0, 0, 100, 200)
|
|
SERVICE = box(60, 0, 66, 30)
|
|
|
|
|
|
def layout():
|
|
return ParkLayout(lawn=QUAD, surfaces=[(box(10, 10, 40, 40), "field")], paths=[box(0, 148, 100, 152)],
|
|
service=[SERVICE], buildings=[(box(70, 60, 80, 70), 5.0)], keepouts=[box(60, 160, 90, 190)])
|
|
|
|
|
|
def world():
|
|
return World(
|
|
land=[box(-100, -100, 200, 300)],
|
|
surfaces=[(box(-30, -30, 130, 230), "urban")],
|
|
roads=[(box(-50, 95, 150, 105), False), (box(-20, -20, 0, 220), False)],
|
|
centerlines=[LineString([(-50, 100), (150, 100)]), LineString([(-5, 150), (10, 150)])],
|
|
buildings=[(box(20, 60, 30, 70), {"height": 9.0, "source": "district"}, False), # ganz im Park
|
|
(box(-6, 80, 4, 90), {"height": 9.0, "source": "district"}, False), # 40 % im Park
|
|
(box(-4, 120, 6, 130), {"height": 9.0, "source": "district"}, False)], # 60 % im Park
|
|
palms=[(50.0, 50.0), (-10.0, 50.0)],
|
|
)
|
|
|
|
|
|
def test_old_roads_and_centerlines_end_at_the_park_edge():
|
|
result = apply_park(world(), layout(), seed=1)
|
|
assert all(poly.intersection(QUAD).area < 1e-6 for poly, _ in result.roads if not poly.equals(SERVICE))
|
|
assert any(poly.contains(Point(-30, 100)) for poly, _ in result.roads)
|
|
assert any(poly.contains(Point(120, 100)) for poly, _ in result.roads)
|
|
assert sorted(line.bounds for line in result.centerlines) == [(-50.0, 100.0, 0.0, 100.0), (100.0, 100.0, 150.0, 100.0)]
|
|
|
|
|
|
def test_old_surfaces_buildings_and_palms_in_the_park_go():
|
|
result = apply_park(world(), layout(), seed=1)
|
|
assert all(poly.intersection(QUAD).area < 1e-6 for poly, kind in result.surfaces if kind == "urban")
|
|
assert sorted((b[0].bounds, b[1]["source"]) for b in result.buildings) == [
|
|
((-6.0, 80.0, 4.0, 90.0), "district"), ((70.0, 60.0, 80.0, 70.0), "osm")]
|
|
assert (-10.0, 50.0) in result.palms and (50.0, 50.0) not in result.palms
|
|
|
|
|
|
def test_layout_goes_in_with_the_lawn_before_the_fields():
|
|
result = apply_park(world(), layout(), seed=1)
|
|
kinds = [kind for _, kind in result.surfaces]
|
|
assert kinds.index("park") < kinds.index("field")
|
|
assert any(kind == "park" and poly.equals(QUAD) for poly, kind in result.surfaces)
|
|
assert result.paths == layout().paths
|
|
assert any(poly.equals(SERVICE) and not bridge for poly, bridge in result.roads)
|
|
|
|
|
|
def test_new_palms_stand_on_lawn_away_from_roads_paths_buildings_and_keepouts():
|
|
result = apply_park(world(), layout(), seed=1)
|
|
new = [Point(p) for p in result.palms if p != (-10.0, 50.0)]
|
|
assert len(new) > 5
|
|
for palm in new:
|
|
assert QUAD.contains(palm) and not box(10, 10, 40, 40).contains(palm)
|
|
assert palm.distance(box(0, 148, 100, 152)) > PALM_CLEARANCE
|
|
assert palm.distance(box(60, 160, 90, 190)) > PALM_CLEARANCE
|
|
assert all(palm.distance(poly) > PALM_CLEARANCE for poly, _ in result.roads)
|
|
assert all(palm.distance(b[0]) > PALM_CLEARANCE for b in result.buildings)
|
|
|
|
|
|
def test_same_seed_gives_the_same_palms():
|
|
assert apply_park(world(), layout(), seed=7).palms == apply_park(world(), layout(), seed=7).palms
|
|
|
|
|
|
def corners(**moved):
|
|
game = {"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}
|
|
return SimpleNamespace(game={**game, **moved})
|
|
|
|
|
|
def test_corners_must_lie_at_a_road_when_it_pulls_back():
|
|
"""Die eine Richtung: die Straße bleibt, wo sie ist, die Ecke steht daneben."""
|
|
roads = [(box(-20, -20, 0, 220), False), (box(100, -20, 120, 220), False)]
|
|
check_corners(corners(), roads)
|
|
with pytest.raises(ParkError, match="Spielecke nw"):
|
|
check_corners(corners(nw=(30.0, 200.0)), roads)
|
|
|
|
|
|
def test_a_road_that_grew_inward_over_the_corner_is_caught():
|
|
"""Die andere Richtung, und der Grund für die Messung gegen den Rand statt gegen die Fläche.
|
|
|
|
Die Westkante der linken Straße ist von x = 0 auf x = 6 nach innen gewandert; die SW- und die
|
|
NW-Ecke liegen jetzt 6 m tief in der Fahrbahn. apply_park() schnitte die verbreiterte Straße
|
|
wortlos wieder auf das alte Viereck zurück — deshalb muss die Prüfung hier abbrechen. Mit dem
|
|
Flächenabstand tut sie das nicht: er ist für eine Ecke mitten in der Straße 0 (die Zusicherung
|
|
unten misst genau das), und die alte Bedingung poly.distance(corner) <= 2.0 ließ den Fall
|
|
durchlaufen.
|
|
"""
|
|
grown = [(box(-20, -20, 6, 220), False), (box(100, -20, 120, 220), False)]
|
|
assert min(poly.distance(Point(0, 0)) for poly, _ in grown) == 0
|
|
with pytest.raises(ParkError, match="Spielecke sw"):
|
|
check_corners(corners(), grown)
|
|
|
|
|
|
def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
|
|
config = load_park(DATA / "flamingo-park.toml")
|
|
park = Polygon([config.game[key] for key in ("sw", "se", "ne", "nw")])
|
|
ring = park.buffer(15, join_style="mitre").difference(park)
|
|
start = World(land=[park.buffer(400)], surfaces=[], roads=[(ring, False)], centerlines=[], buildings=[], palms=[])
|
|
result, messages = apply_flamingo_park(start, DATA / "flamingo-park.toml", seed=20260912)
|
|
assert {"park", "field", "court", "pool", "parking", "sand"} <= {kind for _, kind in result.surfaces}
|
|
assert len([b for b in result.buildings if b[1]["source"] == "osm"]) == 11
|
|
assert result.paths and result.palms
|
|
assert LineString([config.street.start, config.street.end]) in result.centerlines
|
|
# Bekannter Zustand: die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort
|
|
# ausgeschnitten (Task 5). Jede weitere oder andere Warnung ist ein echter Befund.
|
|
assert [m for m in messages if m.startswith("Warnung")] == ["Warnung: Zufahrt schneidet die Sperrfläche tribuene"]
|
|
# Die Flächenzahl gehört mit in den Vergleich: _surface_kind() kennt nur die Sportarten aus
|
|
# FIELD_SPORTS und COURT_SPORTS und lässt jede andere wortlos weg. Taggt OpenStreetMap beim
|
|
# nächsten Abruf ein Feld um — aus "tennis" wird "pickleball", aus "soccer" ein "multi" —,
|
|
# verschwindet es aus dem Park, ohne dass eine Warnung oder ein anderer Test das bemerkt;
|
|
# hier sinkt dann die 14, und der Abzug wird geprüft statt der Park stillschweigend kleiner.
|
|
assert [m for m in messages if m.startswith("Flamingo Park:")] == [
|
|
"Flamingo Park: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit"]
|