The two-layer client rebuild per D-255(a)(b)(e), replacing the _canvas.scale continuous-zoom model with one viewer, one path, all six rungs: - step_canvas_protocol.gd: StepCanvasRequest/Response codec against the T-1181 wire contract — incl. the discovered png_bytes subtlety (rmp_serde without serde_bytes emits a msgpack int-array, not bin; decode repacks via PackedByteArray before load_png_from_buffer) and the extent-echo rule (read the server-clamped extent, never assume the requested one). - step_canvas/ component: transport (six-rung ladder, cursor-anchored scroll steps, edge-scroll/WASD pan with re-request on edge crossing, hard reset-to-Global), RTT terrain layer (Image.set_pixel colorize per the c1 measured ruling, texture.update reuse on step-cross, NEAREST coarse / LINEAR fine per rung), unscaled screen-space annotation sibling (courses + settlement markers at literal px), in-memory LRU cache (Tier 1; T-1183 layers the disk tiers beneath), request lifecycle (pending retry, staleness gate, extent echo). - Full _canvas.scale retirement in the same change: the zoom-scaled canvas model, the _zs compensation family, select_rung / MAX_COVERAGE_M / compute_tile_grid, the orbital-mosaic-vs-window two-path split, _view_zoom/_canonical_fit_zoom — 10 source files deleted; their 14 test suites deleted with them (T-1157 dead-goldens rule; replacement visual-capture coverage is re-scoped T-1157). - Surviving surfaces kept per the ticket: atlas_window_cache.gd's LRU shape (the ticket's named file atlas_window_tile_set.gd was the retiring orchestrator; the real LRU shape lives in atlas_window_cache.gd — cited in step_canvas_cache.gd), overlay colors, legend/overlay-bar chrome, AtlasViewer descend geometry. Determinism boundary per D-255(e): the client interpolates only within the closed server-supplied input set. 7 new gdUnit suites (164 cases) incl. a real extent-echo bug caught by its own test during implementation. Full client suite green (exit 0) with the live-gated suites running against a worktree server build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
524 lines
24 KiB
GDScript
524 lines
24 KiB
GDScript
## T-1138 (D-226 T-1124 amendment §5 entry revision): tests for the planetary
|
||
## AtlasViewer's click-through descent — the fixed view (no drag-pan/wheel-
|
||
## zoom), the pixel-to-DistrictPos inverse mapping (atlas_descend_geometry.gd),
|
||
## and the city-click-wins disambiguation rule.
|
||
class_name TestAtlasDescendEntry
|
||
extends GdUnitTestSuite
|
||
|
||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||
|
||
|
||
# =============================================================================
|
||
# atlas_descend_geometry.gd — pure geometry (no scene tree needed)
|
||
# =============================================================================
|
||
|
||
|
||
## The default n=32 window's real footprint is 32 * 2.048 km = 65.536 km, so
|
||
## the label reads "~66 x 66 km" (rounded) — pins the number the amendment's
|
||
## "honest labeling" resolution depends on.
|
||
func test_reticle_label_extent_matches_district_window_default_n() -> void:
|
||
var label: Dictionary = AtlasDescendGeometry.reticle_label(Vector2(100.0, 100.0))
|
||
var expected_km: float = 32.0 * 2048.0 / 1000.0
|
||
assert_str(label["text"]).is_equal("~%.0f × %.0f km" % [expected_km, expected_km])
|
||
|
||
|
||
func test_reticle_label_position_offsets_from_center() -> void:
|
||
var center := Vector2(50.0, 60.0)
|
||
var label: Dictionary = AtlasDescendGeometry.reticle_label(center)
|
||
var pos: Vector2 = label["position"]
|
||
assert_float(pos.x).is_greater(center.x) # offset to the right, per §5's resolution
|
||
|
||
|
||
## Eight segments (four L-shaped bracket corners, two arms each) — every
|
||
## segment's "from" endpoint is exactly one of the four corner anchors, and no
|
||
## segment has zero length (a degenerate reticle would be invisible).
|
||
func test_reticle_segments_has_eight_nonzero_segments() -> void:
|
||
var segments: Array = AtlasDescendGeometry.reticle_segments(Vector2(200.0, 150.0))
|
||
assert_int(segments.size()).is_equal(8)
|
||
for seg: Array in segments:
|
||
assert_that(seg[0]).override_failure_message(
|
||
"a reticle segment must not be zero-length"
|
||
).is_not_equal(seg[1])
|
||
|
||
|
||
## The reticle's overall bounding box is centered on `center` and sized by
|
||
## DESCEND_RETICLE_SIZE — a regression guard against an off-center or
|
||
## mis-scaled bracket.
|
||
func test_reticle_segments_centered_on_input_point() -> void:
|
||
var center := Vector2(300.0, 300.0)
|
||
var segments: Array = AtlasDescendGeometry.reticle_segments(center)
|
||
var min_pt := Vector2(INF, INF)
|
||
var max_pt := Vector2(-INF, -INF)
|
||
for seg: Array in segments:
|
||
for p: Vector2 in seg:
|
||
min_pt.x = minf(min_pt.x, p.x)
|
||
min_pt.y = minf(min_pt.y, p.y)
|
||
max_pt.x = maxf(max_pt.x, p.x)
|
||
max_pt.y = maxf(max_pt.y, p.y)
|
||
var bbox_center: Vector2 = (min_pt + max_pt) * 0.5
|
||
assert_float(bbox_center.x).is_equal_approx(center.x, 0.01)
|
||
assert_float(bbox_center.y).is_equal_approx(center.y, 0.01)
|
||
|
||
|
||
# =============================================================================
|
||
# district_pos_at — pixel-to-DistrictPos inverse mapping
|
||
# =============================================================================
|
||
|
||
|
||
## No radius (tiny test body fallback, matches the server's own derive_district
|
||
## fallback): the district grid IS the heightmap grid 1:1, so a canvas point
|
||
## maps to the nearest integer district coordinate directly.
|
||
func test_district_pos_at_no_radius_is_1to1_pixel_mapping() -> void:
|
||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||
Vector2(12.4, 7.6), 100.0, 100.0, 0.0
|
||
)
|
||
assert_that(pos).is_equal(Vector2i(12, 8))
|
||
|
||
|
||
func test_district_pos_at_zero_texture_size_is_safe() -> void:
|
||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(Vector2(10.0, 10.0), 0.0, 0.0, 6371.0)
|
||
assert_that(pos).is_equal(Vector2i.ZERO)
|
||
|
||
|
||
## The equatorial center of the texture (px = tex_w/2) is district column
|
||
## district_cols/2 on a body with a radius (equator wraps at district (0,0)
|
||
## per the server's own doc: "district (0,0) sits at lon 0 / the equator").
|
||
##
|
||
## The TRUE equator pixel row is `(tex_h - 1) * 0.5`, NOT `tex_h * 0.5` (PR
|
||
## #187 review, Hoshe): the server's forward map (district_profile.rs:1436)
|
||
## divides by `ta.h.saturating_sub(1)`, so row 0 and row (h-1) are the real
|
||
## clamped pole endpoints and the midpoint between them is `(h-1)/2`. This
|
||
## test originally sampled `tex_h * 0.5`, which is NOT that midpoint for any
|
||
## finite tex_h — it happened to still round to district row 0 under BOTH the
|
||
## pre-fix buggy formula and the fix, at this specific radius/tex_h pair,
|
||
## which is exactly why this equator-only sample masked the ÷tex_h bug
|
||
## instead of catching it (see the off-equator round-trip tests below for the
|
||
## real regression coverage). Corrected here to the genuine equator pixel so
|
||
## this test asserts something true about the fixed formula, not a
|
||
## coincidence of the old one.
|
||
func test_district_pos_at_center_of_texture_is_near_equator_row_zero() -> void:
|
||
var radius_km := 6371.0
|
||
var tex_w := 1024.0
|
||
var tex_h := 512.0
|
||
var true_equator_py: float = (tex_h - 1.0) * 0.5
|
||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||
Vector2(0.0, true_equator_py), tex_w, tex_h, radius_km
|
||
)
|
||
assert_int(pos.y).override_failure_message(
|
||
"the true equator pixel ((tex_h-1)/2) must map to row 0"
|
||
).is_equal(0)
|
||
|
||
|
||
## Panning across the texture's full width sweeps through the FULL district
|
||
## column range (not clamped to a tiny sub-range) — a coarse sanity check that
|
||
## district_cols is actually being derived from the body's circumference, not
|
||
## left at some degenerate default.
|
||
func test_district_pos_at_sweeps_full_column_range_across_texture_width() -> void:
|
||
var radius_km := 6371.0
|
||
var tex_w := 1024.0
|
||
var tex_h := 512.0
|
||
var left: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||
Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||
)
|
||
var right: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||
Vector2(tex_w - 1.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||
)
|
||
# An Earth-radius body has thousands of equatorial districts (circumference
|
||
# ~40,075 km / 2.048 km per district ≈ 19,568) — near-full-width should
|
||
# sweep a large fraction of that, not a handful of columns.
|
||
assert_int(absi(right.x - left.x)).is_greater(1000)
|
||
|
||
|
||
# =============================================================================
|
||
# Off-equator round-trip (PR #187 review, Hoshe — BLOCKING, live-repro'd bug):
|
||
# district_pos_at's row inverse divided by tex_h instead of (tex_h - 1),
|
||
# matching the WRONG symmetry with the column inverse (which correctly
|
||
# divides by the plain tex_w, since longitude wraps and has no edge case).
|
||
# The server's forward map (district_profile.rs:1436) uses
|
||
# ta.h.saturating_sub(1) for the SAME reason the ground-truth inverse
|
||
# (aliveness_probe.rs:511, `row / (ta_h - 1) - 0.5`) does: latitude CLAMPS at
|
||
# the poles, so row 0 / row (h-1) are real endpoints the division must land
|
||
# on exactly. This class of bug — a client-side twin of a server coordinate
|
||
# mapping silently drifting out of sync — is exactly what Tyre's C2 review
|
||
# criterion requires a round-trip drift guard for; that is what every test in
|
||
# this section is. The suite previously missed it because the only
|
||
# real-radius latitude sample was py = tex_h*0.5 (test above), the ONE point
|
||
## where the buggy (÷tex_h) and correct (÷(tex_h-1)) formulas round to the
|
||
# same integer district row — never exercising the asymmetry the fix targets.
|
||
# =============================================================================
|
||
|
||
|
||
## Forward-maps a district row to its heightmap pixel row using the server's
|
||
## OWN formula, transcribed here (district_profile.rs:1436):
|
||
## let lat_frac = (dy * DISTRICT_M / meridian_m).clamp(-0.5, 0.5);
|
||
## let py = (0.5 + lat_frac) * ta.h.saturating_sub(1);
|
||
## `dy` is a DistrictPos.y component (not a pixel) — the caller supplies the
|
||
## same `district_rows_half`-scaled value district_pos_at()'s inverse would
|
||
## need to recover, so this function and district_pos_at() are meant to be
|
||
## exact inverses of one another for any row within the clamped range.
|
||
static func _server_forward_map_row(district_row: int, tex_h: float, radius_km: float) -> float:
|
||
var meridian_m: float = PI * radius_km * 1000.0
|
||
var district_m: float = AtlasDescendGeometry.DISTRICT_M
|
||
var lat_frac: float = clampf((float(district_row) * district_m) / meridian_m, -0.5, 0.5)
|
||
return (0.5 + lat_frac) * (tex_h - 1.0)
|
||
|
||
|
||
## The actual regression: forward-map three off-equator district rows (one
|
||
## per hemisphere, plus a near-pole extreme) to pixel rows via the server's
|
||
## transcribed formula, then assert district_pos_at() recovers each EXACT
|
||
## row from that pixel. Fails outright under the pre-fix ÷tex_h formula for
|
||
## every row here except (coincidentally) very near the equator.
|
||
func test_district_pos_at_round_trips_off_equator_rows_against_server_forward_map() -> void:
|
||
var radius_km := 6238.4 # GJ380c (server/data/systems.db) — a real body, not a round number
|
||
var tex_w := 1024.0
|
||
var tex_h := 512.0
|
||
var col := 400 # arbitrary — this test is about the row axis only
|
||
|
||
# One row per hemisphere (moderate latitude) + one extreme near-pole row.
|
||
# meridian_m / DISTRICT_M ≈ 9,569 for this radius, so district_rows_half
|
||
# ≈ 4,785 — these rows sit well inside that range without saturating the
|
||
# clamp (except the "near pole" case, deliberately close to the edge).
|
||
var test_rows: Array = [-1200, 900, -4700]
|
||
|
||
for district_row: int in test_rows:
|
||
var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km)
|
||
var recovered: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||
Vector2(float(col), forward_py), tex_w, tex_h, radius_km
|
||
)
|
||
assert_int(recovered.y).override_failure_message(
|
||
(
|
||
"row round-trip failed: district_row=%d -> forward pixel py=%.4f -> "
|
||
+ "district_pos_at recovered row=%d (drift=%d)"
|
||
) % [district_row, forward_py, recovered.y, recovered.y - district_row]
|
||
).is_equal(district_row)
|
||
|
||
|
||
## Same round-trip, restated as an explicit non-equator drift guard: the
|
||
## pre-fix bug produced a WRONG but still-integer row (Hoshe's live repro:
|
||
## forward row 50, buggy inverse returned 45 — a 5-district, ~10.2 km drift)
|
||
## — a coarse "is it roughly right" tolerance would have passed that. This
|
||
## asserts EXACT equality specifically at a moderate off-equator row, not an
|
||
## approximate one, so a reintroduced ÷tex_h regression fails loudly again.
|
||
func test_district_pos_at_off_equator_row_is_not_off_by_a_few_districts() -> void:
|
||
var radius_km := 6238.4
|
||
var tex_w := 1024.0
|
||
var tex_h := 512.0
|
||
var district_row := 2500
|
||
var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km)
|
||
var recovered: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||
Vector2(512.0, forward_py), tex_w, tex_h, radius_km
|
||
)
|
||
assert_int(recovered.y).is_equal(district_row)
|
||
|
||
|
||
# =============================================================================
|
||
# T-1142 item 1: is_on_texture() — the letterbox bounds gate
|
||
# =============================================================================
|
||
|
||
|
||
func test_is_on_texture_true_for_a_point_inside_the_texture() -> void:
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, 250.0), 1024.0, 512.0)).is_true()
|
||
|
||
|
||
func test_is_on_texture_true_at_the_top_left_origin() -> void:
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2.ZERO, 1024.0, 512.0)).is_true()
|
||
|
||
|
||
## Half-open range [0, tex_w) x [0, tex_h) — the last valid pixel is tex_w-1 /
|
||
## tex_h-1, NOT tex_w/tex_h themselves (canvas_pt.x == tex_w is one pixel
|
||
## PAST the texture, the classic off-by-one a naive <= bound would miss).
|
||
func test_is_on_texture_false_exactly_at_the_texture_width_bound() -> void:
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(1024.0, 250.0), 1024.0, 512.0)).is_false()
|
||
|
||
|
||
func test_is_on_texture_false_exactly_at_the_texture_height_bound() -> void:
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, 512.0), 1024.0, 512.0)).is_false()
|
||
|
||
|
||
## Jeroen's exact repro shape: a letterbox click lands far PAST the texture
|
||
## width in canvas space (a wide viewport around a 2:1-fitted heightmap).
|
||
func test_is_on_texture_false_for_a_letterbox_point_past_texture_width() -> void:
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(1400.0, 250.0), 1024.0, 512.0)).is_false()
|
||
|
||
|
||
func test_is_on_texture_false_for_negative_coordinates() -> void:
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(-10.0, 250.0), 1024.0, 512.0)).is_false()
|
||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, -10.0), 1024.0, 512.0)).is_false()
|
||
|
||
|
||
# =============================================================================
|
||
# T-1142 item 6a: canonicalize_district_center() — column wraps, row clamps
|
||
# =============================================================================
|
||
|
||
|
||
## Jeroen's own repro column (12276) on a body whose circumference works out
|
||
## to ~11236 districts (body_radius_km chosen so district_extent().cols ==
|
||
## 11236 as closely as the rounding allows) wraps down into range — the exact
|
||
## scenario the letterbox click hit before the bounds gate (item 1) made it
|
||
## unreachable via the UI, but canonicalization is still the correct backstop
|
||
## for any out-of-range center this or a future path constructs.
|
||
func test_canonicalize_wraps_a_column_past_the_circumference() -> void:
|
||
var radius_km := 6371.0 # -> district_extent().cols ~= 19,568 (Earth-like)
|
||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||
var cols: int = int(extent["cols"])
|
||
var out_of_range := Vector2i(cols + 100, 0)
|
||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
out_of_range, radius_km
|
||
)
|
||
assert_int(canonical.x).is_equal(100)
|
||
assert_int(canonical.y).is_equal(0)
|
||
|
||
|
||
## A column ONE past the wrap seam (cols) canonicalizes to column 0 — its
|
||
## "twin" on the other side of the antimeridian. This is the exact identity
|
||
## AtlasWindowCache.make_key() depends on for item 6b (a full-circumnavigation
|
||
## pan hits cache, not a fresh derive) — tested directly on the cache in
|
||
## test_atlas_window_viewer.gd; this pins the canonicalization half alone.
|
||
func test_canonicalize_one_column_past_the_seam_matches_its_twin() -> void:
|
||
var radius_km := 6371.0
|
||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||
var cols: int = int(extent["cols"])
|
||
var past_seam: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
Vector2i(cols, 50), radius_km
|
||
)
|
||
var at_seam: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
Vector2i(0, 50), radius_km
|
||
)
|
||
assert_that(past_seam).override_failure_message(
|
||
"column `cols` and column 0 are the same antimeridian-adjacent point"
|
||
).is_equal(at_seam)
|
||
|
||
|
||
## Negative columns wrap too (Euclidean, not truncating) — a pan that crosses
|
||
## the seam going WEST must land in [0, cols), not go negative.
|
||
func test_canonicalize_wraps_a_negative_column() -> void:
|
||
var radius_km := 6371.0
|
||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||
var cols: int = int(extent["cols"])
|
||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
Vector2i(-5, 0), radius_km
|
||
)
|
||
assert_int(canonical.x).is_equal(cols - 5)
|
||
|
||
|
||
## Rows CLAMP, never wrap — a row past +rows_half pins to +rows_half exactly
|
||
## (the pole), matching the server's normalize_window_center() clamp
|
||
## disposition (latitude terminates, it does not wrap around).
|
||
func test_canonicalize_clamps_a_row_past_the_pole() -> void:
|
||
var radius_km := 6371.0
|
||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||
var rows_half: int = int(extent["rows_half"])
|
||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
Vector2i(0, rows_half + 500), radius_km
|
||
)
|
||
assert_int(canonical.y).is_equal(rows_half)
|
||
var canonical_south: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
Vector2i(0, -rows_half - 500), radius_km
|
||
)
|
||
assert_int(canonical_south.y).is_equal(-rows_half)
|
||
|
||
|
||
## An already-in-range center is a no-op (identity) — canonicalization must
|
||
## never perturb a legitimate, already-valid request.
|
||
func test_canonicalize_is_identity_for_an_in_range_center() -> void:
|
||
var radius_km := 6371.0
|
||
var in_range := Vector2i(500, 100)
|
||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||
in_range, radius_km
|
||
)
|
||
assert_that(canonical).is_equal(in_range)
|
||
|
||
|
||
## No-radius bodies (tiny test bodies) are identity — matching
|
||
## normalize_window_center()'s own no-radius disposition (no periodicity
|
||
## concept at the DistrictPos level for a body with no radius).
|
||
func test_canonicalize_no_radius_is_identity() -> void:
|
||
var anything := Vector2i(99999, -99999)
|
||
assert_that(AtlasDescendGeometry.canonicalize_district_center(anything, 0.0)).is_equal(anything)
|
||
|
||
|
||
# =============================================================================
|
||
# AtlasViewer — fixed view (no drag-pan/wheel-zoom) + click-through descent
|
||
# =============================================================================
|
||
|
||
|
||
## T-1120 capture API (set_view/get_view_offset/get_view_zoom) must survive
|
||
## the removal of user pan/zoom — this is the ticket's own explicit note, and
|
||
## the visual-golden harness depends on it.
|
||
func test_set_view_still_works_after_pan_zoom_removal() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v.set_view(3.0, Vector2(50.0, -20.0))
|
||
assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001)
|
||
assert_that(v.get_view_offset()).is_equal(Vector2(50.0, -20.0))
|
||
|
||
|
||
## Clicking (with no heightmap loaded — the guard AtlasViewer's _gui_input
|
||
## checks first) must not emit a descend request — there is nothing to
|
||
## descend into yet.
|
||
func test_no_descend_signal_without_a_loaded_heightmap() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
var received: Array = []
|
||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||
# _heightmap_texture stays null (no show_body() call) — _gui_input's
|
||
# early-return guard should prevent any click handling at all.
|
||
var mb := InputEventMouseButton.new()
|
||
mb.button_index = MOUSE_BUTTON_LEFT
|
||
mb.pressed = true
|
||
mb.position = Vector2(100.0, 100.0)
|
||
v._gui_input(mb)
|
||
assert_int(received.size()).is_equal(0)
|
||
|
||
|
||
## T-1182 (D-255 stepped Atlas ladder): RegionalScreen no longer wraps
|
||
## AtlasViewer or forwards district_descend_requested — the "regional" nav
|
||
## entry opens the stepped six-rung ladder (StepCanvasViewer) directly at the
|
||
## Global opener (rung 0) — see regional_screen.gd's own doc. This
|
||
## regression-guards the wiring: entering "regional" reaches
|
||
## StepCanvasViewer, not AtlasViewer.
|
||
func test_regional_screen_wraps_step_canvas_viewer_not_atlas_viewer() -> void:
|
||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||
add_child(screen)
|
||
assert_object(screen._viewer).override_failure_message(
|
||
"RegionalScreen must wrap StepCanvasViewer (the stepped ladder) since T-1182,"
|
||
+ " not the retired AtlasViewer heightmap-texture display"
|
||
).is_instanceof(StepCanvasViewer)
|
||
|
||
|
||
# =============================================================================
|
||
# PR #187 review (Araminta — broken promise): the descend reticle must be
|
||
# hidden while hovering a city marker — _hovered_city already flares white as
|
||
# the "click here for city data" signal, and _try_click_city wins the click
|
||
# over descent, so drawing the reticle at the same time promised a descent
|
||
# the click would never perform. State-level tests (per the review's own
|
||
# "state-level is fine" allowance): assert _should_draw_descend_reticle()'s
|
||
# condition directly against _hover_active/_hovered_city/_heightmap_texture,
|
||
# rather than a pixel-diff on the actual draw call.
|
||
# =============================================================================
|
||
|
||
|
||
static func _one_pixel_texture() -> ImageTexture:
|
||
var img := Image.create(1, 1, false, Image.FORMAT_RGB8)
|
||
return ImageTexture.create_from_image(img)
|
||
|
||
|
||
## Hovering EMPTY map (no city under the cursor) -> reticle SHOWS.
|
||
func test_descend_reticle_shows_when_hovering_empty_map() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v._heightmap_texture = _one_pixel_texture()
|
||
v._hover_active = true
|
||
v._hovered_city = {}
|
||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||
"reticle must show when hovering the open map (no city under cursor)"
|
||
).is_true()
|
||
|
||
|
||
## Hovering a CITY marker -> reticle HIDES, even though _hover_active is true
|
||
## (this is the exact bug: pre-fix, _hovered_city was never consulted here).
|
||
func test_descend_reticle_hides_when_hovering_a_city() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v._heightmap_texture = _one_pixel_texture()
|
||
v._hover_active = true
|
||
v._hovered_city = {"name": "Ridgeback", "pos": [10, 20]}
|
||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||
"reticle must hide while hovering a city — the click would open city"
|
||
+ " data (_try_click_city wins), not descend"
|
||
).is_false()
|
||
|
||
|
||
## Not hovering at all (_hover_active false) -> reticle HIDES regardless of
|
||
## _hovered_city — the pre-existing base condition, guarded here so the city
|
||
## fix can't accidentally invert it.
|
||
func test_descend_reticle_hides_when_not_hovering() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v._heightmap_texture = _one_pixel_texture()
|
||
v._hover_active = false
|
||
v._hovered_city = {}
|
||
assert_bool(v._should_draw_descend_reticle()).is_false()
|
||
|
||
|
||
# =============================================================================
|
||
# T-1142 item 1: the letterbox bounds gate, wired into AtlasViewer's own
|
||
# reticle guard and click fall-through (fields default to _tex_w=1024/
|
||
# _tex_h=512, _view_zoom=1.0, _view_offset=ZERO — screen_to_canvas() is
|
||
# therefore the identity transform in these tests, so a screen point maps
|
||
# 1:1 to the same canvas point, matching this file's own established
|
||
## _one_pixel_texture() convention above (the LOADED texture's real
|
||
# dimensions don't matter here — only _tex_w/_tex_h do).
|
||
# =============================================================================
|
||
|
||
|
||
## An on-map screen point (well inside [0, 1024) x [0, 512)) shows the
|
||
## reticle — the ordinary, expected case.
|
||
func test_descend_reticle_shows_for_an_on_texture_point() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v._heightmap_texture = _one_pixel_texture()
|
||
v._hover_active = true
|
||
v._hovered_city = {}
|
||
v._hover_screen_pos = Vector2(500.0, 250.0)
|
||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||
"reticle must show for a point on the heightmap texture"
|
||
).is_true()
|
||
|
||
|
||
## Jeroen's exact repro shape: a letterbox point (canvas x >= tex_w, i.e. past
|
||
## the right edge of a fitted 2:1 heightmap in a wider viewport) HIDES the
|
||
## reticle — the affordance must never promise a descent the click can't
|
||
## honestly perform.
|
||
func test_descend_reticle_hides_for_a_letterbox_point_past_texture_width() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v._heightmap_texture = _one_pixel_texture()
|
||
v._hover_active = true
|
||
v._hovered_city = {}
|
||
v._hover_screen_pos = Vector2(1400.0, 250.0) # past _tex_w=1024
|
||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||
"reticle must hide for a letterbox point past the texture's right edge"
|
||
).is_false()
|
||
|
||
|
||
## Same shape, Y axis — a letterbox point above/below a fitted heightmap
|
||
## (narrow-viewport case) must also hide the reticle.
|
||
func test_descend_reticle_hides_for_a_letterbox_point_past_texture_height() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
v._heightmap_texture = _one_pixel_texture()
|
||
v._hover_active = true
|
||
v._hovered_city = {}
|
||
v._hover_screen_pos = Vector2(500.0, 900.0) # past _tex_h=512
|
||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||
"reticle must hide for a letterbox point past the texture's bottom edge"
|
||
).is_false()
|
||
|
||
|
||
## The click fall-through mirrors the reticle exactly (item 1's "one truth"
|
||
## requirement) — an on-texture click DOES emit district_descend_requested.
|
||
func test_descend_at_emits_for_an_on_texture_click() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
var received: Array = []
|
||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||
v._descend_at(Vector2(500.0, 250.0))
|
||
assert_int(received.size()).override_failure_message(
|
||
"an on-texture click must emit district_descend_requested"
|
||
).is_equal(1)
|
||
|
||
|
||
## A letterbox click is INERT — no signal at all, matching the reticle never
|
||
## having shown a promise there. This is Jeroen's exact repro: a letterbox
|
||
## click must never derive a DistrictPos, on-texture or off.
|
||
func test_descend_at_is_inert_for_a_letterbox_click() -> void:
|
||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||
add_child(v)
|
||
var received: Array = []
|
||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||
v._descend_at(Vector2(1400.0, 250.0))
|
||
assert_int(received.size()).override_failure_message(
|
||
"a letterbox click must be inert — no district_descend_requested at all"
|
||
).is_equal(0)
|