feat(ui): T-1170 B3 — course polyline drawing at District/Quarter; clip restructured per Ruling 3g
_draw() splits into two independent gates: the Layer-1-gated skeleton path (Region chords, clip retained) and the NEW DistrictWindowLayer- gated course path — build_course_render_plan() (pure, render-free- testable) consumed by draw_polyline with _zs-compensated widths and opacities from the Araminta revisit tables; mouth double-rings at Mouth termini only (EdgeDrain/ContinuesBeyondWindow/None: three meanings, one presentation — draw to last point, stop, documented); zero water clip on the course path by construction (courses carry rung-consistent termini). CourseTerminus wire vocabulary kept re- pointable pending A2's real serde names; synthetic Ruling-3h fixtures mean the suites need zero changes when the server payload lands. Real gap found and fixed: window arrival never redrew the nature overlay after the first fit (one line in _on_window_ready — courses would miss every window swap post-pan). Water-clip header rewritten to RESTRUCTURED status (retired on course rungs; permanent at Region until Region goes windowed, T-1143 ruling 2). Revert-verified (visibility-gate bypass -> 4 named failures). geometry-nature 112/112, nature-overlay 58/58, viewer 78/78, zero collateral; full sweep 3928/3928 after the full-import bootstrap; gdlint clean (viewer 1016->1017, pre-existing overage rides T-1158). Tickets: T-1170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -529,6 +529,210 @@ func test_course_class_visible_at_rung_unknown_tag_falls_back_to_empty() -> void
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1170 Ruling 5b/3h: build_course_render_plan() — pure course-polyline
|
||||
# CONSTRUCTION (no draw calls), the course-path counterpart to B2's
|
||||
# build_skeleton_chords(). Synthetic fixtures shaped per Ruling 3h's wire
|
||||
# shape: {class: u8, points: Vec<(i32,i32)> world-metres, terminus: string}
|
||||
# — built BEFORE Dudley's A2 (course inventor) lands, per the ticket brief's
|
||||
# explicit instruction.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
static func _course_fixture(
|
||||
cls: int, points: Array, terminus: String = "None"
|
||||
) -> Dictionary:
|
||||
return {"edge_id": 1, "class": cls, "points": points, "terminus": terminus}
|
||||
|
||||
|
||||
func test_build_course_render_plan_district_trunk_is_visible_and_constructs_points() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0], [4096, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
assert_int(canvas_pts.size()).is_equal(3)
|
||||
assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK)
|
||||
assert_str(plan["terminus"]).is_equal("None")
|
||||
|
||||
|
||||
## District does NOT show streams (COURSE_CLASS_VISIBLE_BY_RUNG: District ==
|
||||
## [TRIBUTARY, TRUNK]) — a stream-class course must construct nothing at
|
||||
## District, even with perfectly well-formed points.
|
||||
func test_build_course_render_plan_district_stream_is_not_visible() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [2048, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).override_failure_message(
|
||||
"streams must not draw at District — only tributary+trunk are visible there"
|
||||
).is_null()
|
||||
|
||||
|
||||
## Quarter rivers return — ALL THREE classes construct at Quarter, including
|
||||
## streams. This is the wave-1 pre-announced revisit actually landing.
|
||||
func test_build_course_render_plan_quarter_stream_is_visible() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [512, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "Quarter", Vector2i.ZERO, 16, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).override_failure_message(
|
||||
"Quarter rivers return — streams must be visible at Quarter"
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Region never carries courses — a course-shaped fixture queried at "Region"
|
||||
## must construct nothing, regardless of class.
|
||||
func test_build_course_render_plan_region_constructs_nothing() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "Region", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_null()
|
||||
|
||||
|
||||
## Ruling 3h: terminus MOUTH is preserved through to the plan — the caller
|
||||
## (the overlay's draw function) reads this to decide whether to draw a
|
||||
## mouth ring at the LAST canvas point.
|
||||
func test_build_course_render_plan_preserves_mouth_terminus() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], "Mouth"
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH)
|
||||
|
||||
|
||||
## EdgeDrain, ContinuesBeyondWindow, and the default None terminus are all
|
||||
## preserved verbatim too — the PLAN doesn't collapse them, the DRAW caller
|
||||
## decides presentation (no ring for any of these three).
|
||||
func test_build_course_render_plan_preserves_edge_drain_and_continues_and_none_termini() -> void:
|
||||
for terminus in ["EdgeDrain", "ContinuesBeyondWindow", "None"]:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], terminus
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_str(plan["terminus"]).is_equal(terminus)
|
||||
|
||||
|
||||
## A course with no `terminus` key at all (an old/malformed payload) defaults
|
||||
## to COURSE_TERMINUS_NONE (the string "None"), never GDScript `null` or an
|
||||
## empty string — matching the class-fallback graceful-decode posture used
|
||||
## throughout this cluster.
|
||||
func test_build_course_render_plan_missing_terminus_defaults_to_none_string() -> void:
|
||||
var course: Dictionary = {
|
||||
"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "points": [[0, 0], [100, 0]]
|
||||
}
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_NONE)
|
||||
|
||||
|
||||
## A course missing `class` entirely falls back to RIVER_CLASS_FALLBACK
|
||||
## (TRUNK) — same posture as the skeleton path's river_class fallback.
|
||||
func test_build_course_render_plan_missing_class_falls_back_to_trunk() -> void:
|
||||
var course: Dictionary = {"edge_id": 1, "points": [[0, 0], [100, 0]]}
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK)
|
||||
|
||||
|
||||
## Fewer than 2 points (a degenerate single-point or empty course) has no
|
||||
## line to draw — must construct null, not a 1-point/0-point polyline.
|
||||
func test_build_course_render_plan_fewer_than_two_points_constructs_nothing() -> void:
|
||||
var one_point: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0]])
|
||||
var no_points: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [])
|
||||
assert_that(
|
||||
AtlasWindowGeometryNature.build_course_render_plan(
|
||||
one_point, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
).is_null()
|
||||
assert_that(
|
||||
AtlasWindowGeometryNature.build_course_render_plan(
|
||||
no_points, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
).is_null()
|
||||
|
||||
|
||||
## Missing `points` key entirely (not just an empty array) must also
|
||||
## construct nothing, not crash on a null/missing field read.
|
||||
func test_build_course_render_plan_missing_points_key_constructs_nothing() -> void:
|
||||
var course: Dictionary = {"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK}
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_null()
|
||||
|
||||
|
||||
## A malformed individual point (not an array, or too short) is skipped —
|
||||
## not fatal to the whole polyline, matching build_skeleton_chords()'s own
|
||||
## "skip the bad entry, keep going" posture — as long as >= 2 valid points
|
||||
## remain.
|
||||
func test_build_course_render_plan_malformed_point_is_skipped_not_fatal() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "not a point", [2048, 0], [4096, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
assert_int(canvas_pts.size()).override_failure_message(
|
||||
"the malformed point must be skipped, leaving exactly the 3 well-formed points"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
## Malformed points that leave FEWER than 2 valid entries must still
|
||||
## construct null (the "too many bad points" case, distinct from "some bad
|
||||
## points but enough good ones remain" above).
|
||||
func test_build_course_render_plan_malformed_points_leaving_too_few_constructs_nothing() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "bad", "also bad"]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_null()
|
||||
|
||||
|
||||
## Points are WORLD METRES (Ruling 3h), not heightmap pixels — cross-checked
|
||||
## against world_m_to_canvas_local() called manually, proving the plan's
|
||||
## conversion path matches the documented one-fewer-step-than-skeleton
|
||||
## pipeline (no layer1_pixel_to_world_m() involved at all).
|
||||
func test_build_course_render_plan_points_are_world_metres_not_pixels() -> void:
|
||||
var held_center := Vector2i(5, 5)
|
||||
var held_n := 64
|
||||
var world_pt := Vector2(10240.0, -4096.0) # 5 districts east, 2 north of origin
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK,
|
||||
[[int(world_pt.x), int(world_pt.y)], [0, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var expected: Vector2 = AtlasWindowGeometryNature.world_m_to_canvas_local(
|
||||
world_pt, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
assert_that(canvas_pts[0]).is_equal_approx(expected, Vector2.ONE * 0.01)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1170 Ruling 5c: course_class_width_px() / course_class_opacity() —
|
||||
# functional-default companion tables to COURSE_CLASS_VISIBLE_BY_RUNG.
|
||||
|
||||
@@ -512,3 +512,68 @@ func test_segment_touches_drawn_water_true_when_only_midpoint_is_on_water() -> v
|
||||
+ " on drawn water must still be clipped — this is the exact failure"
|
||||
+ " mode an endpoints-only rule would miss (Ruling 3g's ask)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1170 Ruling 5b (B3): _draw_course_path() early-return gating — the SAME
|
||||
# "call the function directly when its early-return happens BEFORE any
|
||||
# draw_*() call" precedent test_draw_with_null_viewer_is_a_noop() and
|
||||
# test_draw_with_zero_grid_dims_returns_before_any_draw_call() already
|
||||
# establish. Every case below returns before _draw_one_course() is ever
|
||||
# reached, so calling _draw_course_path() directly (no SubViewport/render
|
||||
# context) is safe. This is a SEPARATE data source/gate from the Layer-1
|
||||
# skeleton path above — none of these tests touch _layer1 at all.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The overlay-bar "gen_rivers" toggle gates the course path too — the SAME
|
||||
## toggle the skeleton path uses (one player-facing "rivers" control covers
|
||||
## both presentation surfaces, per the ruling).
|
||||
func test_draw_course_path_returns_before_any_draw_when_gen_rivers_is_off() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.overlay_visibility["gen_rivers"] = false
|
||||
stub.district_window = {
|
||||
"n": 64, "granularity_v2": "District",
|
||||
"courses": [{"class": 2, "points": [[0, 0], [100, 0]], "terminus": "None"}],
|
||||
}
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path() # must return before draw_polyline() — no crash outside a render context
|
||||
|
||||
|
||||
## No district window at all (single-window mode hasn't arrived yet) — the
|
||||
## course path must return cleanly, not crash on a null window read.
|
||||
func test_draw_course_path_returns_before_any_draw_when_no_window() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = null
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
|
||||
## Ruling 3h decode tolerance: a window WITHOUT a `courses` key at all (the
|
||||
## old/pre-A2 payload shape) must draw NOTHING at District/Quarter except
|
||||
## mouths-on-land from the skeleton (that's the OTHER path's job) — this
|
||||
## path itself must simply return, not error or fall back to a dot-scatter.
|
||||
func test_draw_course_path_missing_courses_field_is_tolerated() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = {"n": 64, "granularity_v2": "District"} # no "courses" key
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
|
||||
## An explicitly present but EMPTY courses array must also be tolerated
|
||||
## cleanly (the loop simply iterates zero times).
|
||||
func test_draw_course_path_empty_courses_array_is_tolerated() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = {"n": 64, "granularity_v2": "District", "courses": []}
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
|
||||
## A `courses` field that is present but the WRONG TYPE (not an Array — e.g.
|
||||
## a malformed/corrupted payload) must be tolerated the same way as a
|
||||
## missing field, not crash attempting to iterate a non-Array.
|
||||
func test_draw_course_path_non_array_courses_field_is_tolerated() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = {"n": 64, "granularity_v2": "District", "courses": "not an array"}
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
Reference in New Issue
Block a user