World.centerlines wird zu World.graph: apply_street() und apply_park() schneiden und verlängern jetzt den Straßengraphen statt einer losen Liste von Mittellinien, mit den neuen roads.cut_edges()/attach() als Gegenstück zum bestehenden Schneiden der Straßenflächen. apply_park() schneidet dabei einmal am Parkviereck (nicht je Sperrfläche - das schneidet tatsächlich nichts, was der Parkumriss nicht schon geschnitten hätte). check_corners() bleibt unverändert gegen world.roads (den Korridor) messen, mit einem erklärenden Docstring-Absatz und einer Regressionswache dafür in den Tests. MIN_CENTERLINE (15 m) entfällt zugunsten von MIN_EDGE (5 m): Der alte Grund - Laternen wählten ihre Straßenseite über den Listenindex - fällt weg, sobald eine spätere Aufgabe die Seite aus der Geometrie bestimmt. test_park_street.py dokumentiert das jetzt: die beiden 11-m-Enden der geschnittenen Gasse bleiben deshalb stehen, statt wie vorher zu verschwinden. Beim Nachrechnen der Prüfkörper aus dem Auftrag fielen zwei echte Fehler auf: Der Reste-unter- Mindestlänge-Test schnitt ursprünglich bis übers Streckenende hinaus, sodass der Rest schon geometrisch verschwand und der MIN_EDGE-Filter nie geprüft wurde - jetzt schneidet er innerhalb der Strecke, sodass tatsächlich ein zu kurzes Stück verworfen wird. Und die neue Korridor-Regressionswache verglich (12-7)/2 > 2.5, was mit den echten Maßen immer falsch ist (2.5 > 2.5); korrigiert auf >=, denn die Halbdifferenz trifft die Toleranz exakt. Ausserdem musste eine Test-Fixture in test_park_apply.py von einem 5-m-Reststück (exakt an der neuen MIN_EDGE-Grenze, hätte also entgegen der ursprünglichen Testabsicht überlebt) auf 3 m verkürzt werden, damit der Test weiterhin prüft, dass echte Kurzreste wegfallen. run.py bleibt unangetastet: Es ist bereits durch eine frühere Aufgabe gebrochen und wird von der nächsten Aufgabe repariert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
145 lines
7.9 KiB
Python
145 lines
7.9 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
|
|
from leonida.roads import Edge, Graph, Node
|
|
|
|
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)],
|
|
# Der zweite Ast endet 3 m vor dem Parkviereck (statt der ehemals 5 m): Genau 5 m wäre
|
|
# MIN_EDGE selbst, und der Rest bliebe stehen (5.0 < MIN_EDGE ist falsch) statt zu
|
|
# verschwinden - dieser Test prüft aber, dass ein echter Kurzrest wegfällt.
|
|
graph=Graph(nodes=[Node((-50.0, 100.0)), Node((150.0, 100.0)), Node((-3.0, 150.0)), Node((10.0, 150.0))],
|
|
edges=[Edge(0, 1, [(-50.0, 100.0), (150.0, 100.0)], 12.0, "wohnstrasse"),
|
|
Edge(2, 3, [(-3.0, 150.0), (10.0, 150.0)], 6.0, "gasse")]),
|
|
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)
|
|
lines = [LineString(e.points) for e in result.graph.edges]
|
|
assert sorted(line.bounds for line in lines) == [(-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_eckenpruefung_misst_den_korridor_nicht_die_fahrbahn():
|
|
"""Regressionswache: world.roads ist der Korridor, und die Prüfung gehört an ihn.
|
|
|
|
Eine 12-m-Straße bekommt 7 m Fahrbahn. Gegen die Fahrbahn gemessen lägen die Ecken des
|
|
Parks 2,5 m weiter draußen als erlaubt, und jeder Lauf bräche ab.
|
|
"""
|
|
from leonida.section import SECTION
|
|
assert SECTION["wohnstrasse"].corridor - SECTION["wohnstrasse"].carriageway == 5.0
|
|
# Die Halbdifferenz trifft die Standardtoleranz von check_corners (2.5 m) exakt: schon der
|
|
# kleinste real gemessene Eckenabstand (0,14 m, siehe check_corners()) reicht addiert, um
|
|
# jeden Lauf abbrechen zu lassen, wenn hier gegen die Fahrbahn statt den Korridor gemessen wird.
|
|
assert (SECTION["wohnstrasse"].corridor - SECTION["wohnstrasse"].carriageway) / 2 >= 2.5
|
|
|
|
|
|
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)], graph=Graph(), 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 any(e.points == [tuple(config.street.start), tuple(config.street.end)] for e in result.graph.edges)
|
|
# 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"]
|