Files
gta6/tools/extract/tests/test_park_apply.py
T
knsoandClaude Sonnet 5 8bc8ca19fc feat(extract): Flamingo Park hängt seine Straße an den Graphen
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>
2026-09-17 10:41:08 +02:00

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"]