Files
settled-reach/client/tests/test_step_canvas_viewer.gd
T
jpmschweitzerandClaude Fable 5 c1a97166c5 feat(ui): cap rung extents to the body + fit/center the Global canvas (T-1189, T-1192)
The two client fixes for the Atlas frames Jeroen flagged. Region rung no
longer requests more planet than exists: cap_extent_to_body() caps the
viewport-fit extent at the body's own region grid (the Global canvas
extent echo — cols=regions_per_equator, rows=cols/2), matched by gridunit
SPACING not rung name, applied before the request/cache key is built;
cold-start scroll-before-Global-echo requests uncapped and lets the
server clamp (documented). Kills both the side-by-side continent repeat
and the past-the-pole stripe smear.

Global opener now fit-scales and centers through one shared letterbox
mechanism (center_offset/integer_fit_scale/fit_scale, 75%-coverage
integer-vs-fractional decision, NEAREST already forced on orbital rungs
per D-255's escape hatch) also used for the Region cap's letterbox
remainder. Legend column reserved before fitting — pinned to the legend's
own width by a cross-constant test, never overlapping the canvas.

Also fixes a latent crash: NOTIFICATION_RESIZED fires mid-_ready() before
children exist; null guard in the resize path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:52:06 +02:00

655 lines
31 KiB
GDScript

## T-1182 tests: StepCanvasViewer — the rung transport state machine
## (enter() lands on the Global opener, scroll steps through the ladder,
## overlay toggle wiring, pan-edge re-request, edge-scroll) and
## RegionalScreen's re-entry guard against the new viewer. test_mode
## (SimBridge default outside SR_LIVE=1) means request_step_canvas() is a
## silent no-op — these tests exercise client-side state only, no live
## server needed.
class_name TestStepCanvasViewer
extends GdUnitTestSuite
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
## GJ1c's own region-grid shape from the T-1183 eyeball (177x88) — reused
## across the T-1189/T-1192 section below so every test is grounded in the
## actual regression captured in
## .cache/screenshots/t1183-eyeball-run2/02-region.png.
const GJ1C_GLOBAL_EXTENT := Vector2i(177, 88)
func test_enter_lands_on_the_global_opener() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
func test_get_body_id_reflects_the_entered_body() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
assert_str(v.get_body_id()).is_equal("")
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
assert_str(v.get_body_id()).is_equal("GJ380c")
## Scrolling one notch descends the ladder — cursor-anchored, so a cursor
## position must be supplied; the rung index advances by exactly one.
func test_scroll_rung_descends_one_notch() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0))
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION)
func test_scroll_rung_clamps_at_the_deepest_rung() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
for _i in range(10):
v._scroll_rung(1, Vector2(400.0, 300.0))
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_CHUNK)
func test_reset_to_global_returns_from_a_deep_rung() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0))
v._scroll_rung(1, Vector2(400.0, 300.0))
v._reset_to_global()
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
## PR #203 review (Hoshe finding 2): the hard full-zoom-out reset — a
## scroll-out gesture while ALREADY at Global (rung 0), with the view
## drifted from the canonical un-panned frame, must snap the view back to
## center (Jeroen's explicit HARD condition, carried from the retired
## viewer's own _maybe_reset_to_canonical_frame()). Behavioral, through the
## real input entry point (_scroll_rung with direction=-1), not a direct
## _reset_to_global() call — this is what would have caught the dead-code
## regression (scroll_step() clamping at index 0 meant _scroll_rung()
## returned before ever reaching a reset call).
func test_scroll_out_at_global_after_a_pan_resets_the_view() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
v._apply_pan_delta(Vector2(1.0, 0.0), 1.0) # drift the view off-center
assert_bool(v._is_global_view_drifted()).override_failure_message(
"test setup: a pan at Global must actually drift the view"
).is_true()
v._scroll_rung(-1, Vector2(400.0, 300.0)) # scroll OUT — already at rung 0
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
assert_bool(v._is_global_view_drifted()).override_failure_message(
"a scroll-out past the top of the ladder must hard-reset the drifted"
+ " Global view back to its canonical (centered) frame"
).is_false()
## The inverse guard: scrolling out while ALREADY at the canonical
## (un-drifted) Global frame must stay a no-op — the reset is edge-triggered
## on genuine drift, not a per-scroll unconditional reset.
func test_scroll_out_at_undrifted_global_is_a_no_op() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(-1, Vector2(400.0, 300.0))
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
assert_bool(v._is_global_view_drifted()).is_false()
func test_overlay_visibility_defaults_to_off_for_every_toggle() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
for def: Dictionary in v.get_overlay_defs():
assert_bool(v.is_overlay_visible(def["id"])).is_false()
func test_set_overlay_visible_updates_state() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.set_overlay_visible("gen_dw_temp", true)
assert_bool(v.is_overlay_visible("gen_dw_temp")).is_true()
func test_set_overlay_visible_unknown_id_is_a_no_op() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.set_overlay_visible("not_a_real_overlay", true)
assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false()
# =============================================================================
# RegionalScreen re-entry guard (BUG 2 lineage, carried forward from the
# retired AtlasWindowViewer-era regression) — now against StepCanvasViewer.
# =============================================================================
func test_regional_screen_repeat_enter_for_the_same_body_is_a_no_op() -> void:
var screen: RegionalScreen = auto_free(RegionalScreen.new())
add_child(screen)
var body: Dictionary = {"body_id": "GJ380c", "body_radius_km": 6238.4}
screen.enter({"body": body, "system": {}})
screen._viewer._scroll_rung(1, Vector2(400.0, 300.0))
assert_str(screen._viewer.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION)
screen.enter({"body": body, "system": {}})
# A no-op re-entry must NOT reset the held rung back to Global — that
# would be the exact "repeat enter tears down in-flight state" class the
# retired viewer's own cold-start guard existed to prevent.
assert_str(screen._viewer.get_held_rung()).override_failure_message(
"a repeat enter() for the SAME body must not reset the held rung"
).is_equal(StepCanvasTransport.RUNG_REGION)
func test_regional_screen_different_body_still_re_enters() -> void:
var screen: RegionalScreen = auto_free(RegionalScreen.new())
add_child(screen)
screen.enter({"body": {"body_id": "GJ380c", "body_radius_km": 6238.4}, "system": {}})
screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}})
assert_str(screen._viewer.get_body_id()).is_equal("OtherBody")
## Relocated from test_atlas_descend_entry.gd (T-1182 PR #203 review — the
## AtlasViewer cluster orphan retirement, Tyre finding). This is the one live
## regression guard from that suite: RegionalScreen must wrap StepCanvasViewer
## (the stepped ladder), never fall back to the now-deleted AtlasViewer
## heightmap-texture display — a direct type-identity check, distinct from
## the behavioral tests above (which would only fail indirectly, via a
## missing method, if this ever regressed).
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 #203 review (Hoshe notes): pan-edge re-request (_maybe_refloat) and
# edge-scroll pan — previously untested. _maybe_refloat() only does anything
# once the terrain layer holds a real texture (get_footprint_px() is
# ZERO/inert until then), so these tests drive StepCanvasTerrainLayer.
# rebuild_from_canvas() directly (bypassing the network — a decoded canvas
# dict is all it needs) to put the viewer into the "holding a real canvas"
# state _maybe_refloat's early-out guards against.
# =============================================================================
static func _synthetic_canvas(width: int, height: int) -> Dictionary:
return {
"width": width,
"height": height,
"morphology": null,
"elev_q": null,
"moisture_q": null,
"vegetation": null,
"glaciation": null,
"temp_dc": [],
"settlement_id": [],
"courses": [],
"cliffs": [],
}
func test_maybe_refloat_is_inert_before_any_canvas_has_arrived() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0)) # District — footprint still ZERO, nothing arrived
var world_center_before: Vector2 = v._world_center
v._maybe_refloat()
assert_that(v._world_center).is_equal(world_center_before)
## A small pan (well under half the canvas footprint) must NOT re-float —
## the held canvas keeps drawing, no re-request (§4/§5's "only when a pan
## carries the view past the held window's edge").
func test_maybe_refloat_does_not_refloat_on_a_small_pan() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0) # a real viewport size — _maybe_refloat's
# drift math is relative to get_rect().size's own center; leaving this at
# the default ZERO would make screen_center ZERO too, so even a tiny
# view_offset reads as "drifted past the canvas's own half-footprint"
# (drift = view_offset + half, threshold = half*0.5) — a test-harness
# artifact, not the behavior under test.
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(64, 64), v.get_held_rung(), "")
# _scroll_rung() re-centers view_offset to ZERO on arrival, which under a
# real 800x600 viewport already puts the canvas center near screen center
# (both small relative to the viewport) — center the canvas explicitly so
# "small pan" starts from a known-centered baseline.
v._view_offset = v.size * 0.5 - v._terrain_layer.get_footprint_px() * 0.5
var world_center_before: Vector2 = v._world_center
v._view_offset += Vector2(2.0, 0.0) # tiny drift, far under half the footprint
v._maybe_refloat()
assert_that(v._world_center).override_failure_message(
"a small pan must not re-float the held canvas"
).is_equal(world_center_before)
## A large pan (past half the canvas footprint) DOES re-float — new
## world_center, view_offset reset to ZERO (the canvas re-centers under the
## new request).
func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(64, 64), v.get_held_rung(), "")
var world_center_before: Vector2 = v._world_center
var footprint: Vector2 = v._terrain_layer.get_footprint_px()
v._view_offset = Vector2(footprint.x, 0.0) # far past half the footprint
v._maybe_refloat()
assert_that(v._world_center).override_failure_message(
"a pan past the edge threshold must re-float (new world_center)"
).is_not_equal(world_center_before)
assert_that(v._view_offset).override_failure_message(
"re-floating resets view_offset to ZERO (the canvas re-centers)"
).is_equal(Vector2.ZERO)
## Global never re-floats on pan (D-255(a): its canvas is the whole body,
## no edge to cross) — even with a real texture held and a huge drift.
func test_maybe_refloat_is_a_no_op_at_global_rung() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(200, 100), v.get_held_rung(), "")
var world_center_before: Vector2 = v._world_center
v._view_offset = Vector2(9_999.0, 9_999.0)
v._maybe_refloat()
assert_that(v._world_center).is_equal(world_center_before)
# =============================================================================
# Edge-scroll: suppression conditions + direction.
# =============================================================================
func test_edge_scroll_suppressed_without_application_focus() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = false
v._last_mouse_pos = Vector2(2.0, 300.0) # well inside the edge margin
assert_bool(v._is_cursor_edge_scrolling()).is_false()
func test_edge_scroll_suppressed_when_cursor_has_never_moved_over_the_control() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
# _last_mouse_pos defaults to (-1, -1) — an impossible in-bounds position,
# so edge-scroll never fires before the mouse has moved over the control
# at least once (matches the retired viewer's own documented contract).
assert_bool(v._is_cursor_edge_scrolling()).is_false()
func test_edge_scroll_active_near_the_left_edge() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = true
v._last_mouse_pos = Vector2(2.0, 300.0)
assert_bool(v._is_cursor_edge_scrolling()).is_true()
var direction: Vector2 = v._edge_scroll_direction()
assert_float(direction.x).is_less(0.0)
assert_float(direction.y).is_equal(0.0)
func test_edge_scroll_active_near_the_right_edge() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = true
v._last_mouse_pos = Vector2(798.0, 300.0)
var direction: Vector2 = v._edge_scroll_direction()
assert_float(direction.x).is_greater(0.0)
func test_edge_scroll_inactive_well_inside_the_viewport() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = true
v._last_mouse_pos = Vector2(400.0, 300.0) # dead center — far from any edge
assert_bool(v._is_cursor_edge_scrolling()).is_false()
# =============================================================================
# T-1183: disk-cache sweep-trigger wiring
# =============================================================================
## enter() must invoke the disk cache's (2a) visit sweep for the entered
## body — a smoke test that the wiring exists and doesn't crash; the sweep
## LOGIC itself (what gets evicted and why) is covered exhaustively by
## test_step_canvas_disk_cache.gd. Uses a distinctive body_id with nothing
## ever cached under it, so the sweep is a true no-op read (no writes to the
## real user://atlas_cache/ directory this test could leak).
func test_enter_runs_the_disk_cache_visit_sweep_without_crashing() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "T1183_sweep_smoke_test_body", "body_radius_km": 6238.4}, {})
# If the wiring is broken (e.g. calling a method that doesn't exist), the
# enter() call itself would already have failed above — reaching here
# with the expected held rung is the assertion.
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
## The coarse background sweep timer exists, is not per-frame (a real
## Timer node, not a _process()-driven counter), autostarts, and is set to
## the documented coarse interval — never a sub-frame or per-frame value.
func test_disk_sweep_timer_is_coarse_and_autostarts() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
var timer: Timer = v.get_node("DiskSweepTimer")
assert_object(timer).is_not_null()
# Godot resets Timer.autostart to false once the timer has actually
# started after entering the tree (documented engine behavior — the flag
# is a one-shot "start me on _ready()" instruction, not a persistent
# state mirror). The real behavioral guarantee is "the timer is running,
# unpaused, without anyone having to call start() explicitly" —
# is_stopped() == false is the correct read of that.
assert_bool(timer.is_stopped()).override_failure_message(
"the disk sweep timer must autostart running, no explicit start() call needed"
).is_false()
assert_float(timer.wait_time).override_failure_message(
"the disk sweep timer must be coarse (minutes), never a per-frame interval"
).is_greater_equal(60.0)
## The timer's timeout must actually route to the disk cache's
## run_background_sweep() for the currently-entered body — verified by
## invoking the private handler directly (the same "call the handler, don't
## wait on a real Timer" pattern used elsewhere in this cluster for
## non-blocking test speed) against an injected-root request so this test
## touches no real cache files.
func test_disk_sweep_timeout_handler_runs_background_sweep_for_the_current_body() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "T1183_sweep_smoke_test_body", "body_radius_km": 6238.4}, {})
# No live server, no cached entries for this body — the assertion is
# that calling the handler does not crash and leaves the (empty) cache
# consistent, mirroring the enter()-sweep smoke test above.
v._on_disk_sweep_timeout()
assert_int(v.get_request().get_disk_cache().entry_count("T1183_sweep_smoke_test_body")).is_equal(0)
# =============================================================================
# T-1189: extent cap wired end-to-end (viewer -> transport), plus the
# cache-key consistency the ticket calls out explicitly ("capping happens
# BEFORE the request is issued so keys stay consistent"). No live server —
# a Global canvas is landed via the SAME Tier-1 cache-hit path
# StepCanvasRequest's own tests use (get_cache().put() + request_now()'s
# synchronous cache-hit emit), so the full _on_canvas_ready wiring runs for
# real rather than being shortcut.
# =============================================================================
## Land a Global canvas of the given size into the viewer's OWN cache (Tier
## 1), then fire the request that the real cache-hit path serves
## synchronously — same mechanism test_step_canvas_request.gd's own
## cache-hit tests use, now driven through the viewer so _on_canvas_ready()
## and _global_body_extent actually populate through the real signal wiring.
static func _land_global_canvas(v: StepCanvasViewer, width: int, height: int) -> void:
var req: Variant = v.get_request()
req.get_cache().put(
v.get_body_id(),
"Global",
Vector2i.ZERO,
Vector2i.ZERO,
TestStepCanvasViewer._synthetic_canvas(width, height)
)
v._fire_request() # Global's own request — served from the cache hit just landed
func test_global_canvas_arrival_populates_the_body_extent_cap_source() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._global_body_extent).is_equal(GJ1C_GLOBAL_EXTENT)
## The T-1183 eyeball regression itself: once the Global echo has landed,
## scrolling to Region and firing its request must produce a CAPPED extent
## — never the raw viewport-fit 384x216 that overran the body on both axes.
func test_region_request_extent_is_capped_to_the_landed_global_extent() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0) # the T-1183 eyeball's own viewport
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # descend to Region — fires the capped request
var extent: Vector2i = v._request_extent()
assert_int(extent.x).override_failure_message(
"Region's requested extent must never exceed the body's own region-grid width"
).is_less_equal(GJ1C_GLOBAL_EXTENT.x)
assert_int(extent.y).override_failure_message(
"Region's requested extent must never exceed the body's own region-grid height"
).is_less_equal(GJ1C_GLOBAL_EXTENT.y)
assert_that(extent).is_equal(GJ1C_GLOBAL_EXTENT) # 1920x1080 viewport-fit exceeds 177x88 on both axes
## Cold-start fallback (documented on StepCanvasViewer._request_extent()):
## before ANY Global response has landed, `_global_body_extent` is still
## ZERO — the Region request must go out UNCAPPED (server clamps
## independently) rather than silently collapsing to a zero-cell request.
func test_region_request_extent_is_uncapped_before_the_global_echo_lands() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
# No _land_global_canvas() call — simulates scrolling in before the
# FIRST (Global) request's response has arrived.
v._scroll_rung(1, Vector2(960.0, 540.0))
var extent: Vector2i = v._request_extent()
var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "Region")
assert_that(extent).override_failure_message(
"before the Global echo lands, the Region request must be the ordinary"
+ " uncapped viewport-fit extent, not silently zeroed"
).is_equal(uncapped)
## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out): the
## cap must be applied BEFORE _fire_request() builds the request, so the
## extent that becomes part of the cache key is the SAME capped value that
## gets served — a request for "the same spot" must always resolve to the
## same key, capped or not, never a key built from one extent and served
## under another.
func test_capped_extent_matches_what_the_request_actually_sends() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region — fires _fire_request() internally
# _request_extent() is the SAME function _fire_request() calls to build
# the outbound request/cache key — calling it again here must be
# idempotent and match what was actually requested (no separate,
# divergent cap path).
var extent_now: Vector2i = v._request_extent()
assert_that(extent_now).is_equal(GJ1C_GLOBAL_EXTENT)
# The Tier-1 cache key StepCanvasCache builds from this SAME extent must
# be a real, findable key once a response for it lands — proving the
# capped extent (not the raw viewport-fit one) is what keys the cache.
var req: Variant = v.get_request()
var center: Vector2i = StepCanvasTransport.snap_to_gridunit(v._world_center, "Region")
var canvas := TestStepCanvasViewer._synthetic_canvas(GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
req.get_cache().put("T1189_extent_letterbox_test_body", "Region", center, extent_now, canvas)
assert_bool(
req.get_cache().has("T1189_extent_letterbox_test_body", "Region", center, GJ1C_GLOBAL_EXTENT, 0)
).override_failure_message(
"a cache entry stored under the CAPPED extent must be reachable"
+ " under that same capped extent — key consistency"
).is_true()
## Shape-generic guard: District's spacing differs from Global's, so its
## request extent must be completely unaffected by a landed Global canvas —
## proving the cap is spacing-keyed, not applied indiscriminately to every
## rung once a Global extent is known.
func test_district_request_extent_is_never_capped_by_the_global_extent() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region
v._scroll_rung(1, Vector2(960.0, 540.0)) # District
var extent: Vector2i = v._request_extent()
var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "District")
assert_that(extent).is_equal(uncapped)
# =============================================================================
# T-1189/T-1192: shared letterbox mechanism — centering + Global fit scale.
# =============================================================================
## T-1192's own headline defect: the Global canvas must no longer draw
## top-left-anchored at Vector2.ZERO — once a canvas lands, the viewer must
## have computed a non-zero centering offset (unless the canvas happens to
## exactly fill the viewport, not the case here).
func test_global_canvas_arrival_centers_the_view_not_top_left() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._view_offset).override_failure_message(
"a landed Global canvas smaller than the viewport must be CENTERED,"
+ " never left at the raw top-left Vector2.ZERO anchor"
).is_not_equal(Vector2.ZERO)
## D-255 texel-exactness (T-1192): at a large (4K-class) viewport, GJ1c's
## 885x440 raw footprint (177x88 texels x 5x5 shallow display ratio) clears
## the coverage bar even AFTER the legend column is reserved, so the
## INTEGER fit wins outright — verified end-to-end through the real viewer
## wiring, not just the pure transport function this mirrors. (The T-1183
## reference 1920x1080 viewport is deliberately NOT used here — at that
## size the legend-column reservation starves the integer candidate below
## the coverage bar and the fractional escape hatch fires instead, covered
## separately by test_global_canvas_uses_fractional_fit_when_legend_column_
## starves_the_integer_fit() below.)
func test_global_canvas_scale_is_an_integer_multiple_of_the_raw_footprint() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(3840.0, 2160.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
assert_that(v._canvas.scale).is_equal(Vector2(v._canvas_scale, v._canvas_scale))
assert_float(v._canvas_scale).override_failure_message(
"the Global fit scale must be a whole number of texture pixels"
+ " when it clears the coverage bar (D-255 texel-exactness)"
).is_equal_approx(roundf(v._canvas_scale), 0.001)
## Fixed rungs must NEVER receive the Global fit multiplier — `_canvas.scale`
## stays 1.0 once the player has descended past Global, even though a
## Global canvas was landed earlier in the same session.
func test_fixed_rung_canvas_scale_stays_one_after_descending_from_global() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region
v._terrain_layer.rebuild_from_canvas(
TestStepCanvasViewer._synthetic_canvas(200, 100), v.get_held_rung(), ""
)
v._recompute_canvas_transform()
assert_float(v._canvas_scale).is_equal_approx(1.0, 0.001)
assert_that(v._canvas.scale).is_equal(Vector2.ONE)
## Legend non-overlap (T-1192: "lay the legend out beside the canvas... never
## over it"): the legend panel sits at a fixed left-column position
## (PANEL_MARGIN, ...) with a known width — once a Global canvas is landed
## and centered, its drawn rect's LEFT edge must be at or past the legend's
## own right edge, never underneath it.
func test_global_canvas_left_edge_never_overlaps_the_legend_column() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
var canvas_left_edge: float = v._view_offset.x
assert_float(canvas_left_edge).override_failure_message(
"the Global canvas's drawn left edge must be at or past the reserved"
+ " legend column — the legend must never be covered by the map"
).is_greater_equal(StepCanvasTransport.LEGEND_COLUMN_PX - 0.01)
## Resize must re-fit/re-center a HELD canvas, not just a freshly-arriving
## one — _notification(NOTIFICATION_RESIZED) wires _recompute_canvas_transform()
## for exactly this case.
func test_resize_recenters_an_already_held_global_canvas() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
var offset_before: Vector2 = v._view_offset
v.size = Vector2(1280.0, 720.0)
v._notification(Control.NOTIFICATION_RESIZED)
assert_that(v._view_offset).override_failure_message(
"a resize must re-center the held canvas for the NEW viewport size"
).is_not_equal(offset_before)
## Small-canvas fallback (D-255's own escape hatch): once the legend column
## eats enough of the available width that the integer fit falls under
## FIT_MIN_COVERAGE_RATIO, the viewer must fall back to the fractional fit
## rather than settling for a sparse integer frame — end-to-end through the
## real viewer, mirroring test_fit_scale_falls_back_to_fractional_fit_on_
## excessive_letterboxing in test_step_canvas_transport.gd.
func test_global_canvas_uses_fractional_fit_when_legend_column_starves_the_integer_fit() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {})
TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y)
# GJ1c at 1920x1080 with the legend column reserved: 2x no longer fits
# (1770 > 1628 available), 1x covers only ~41% of the tighter axis — well
# under the 75% coverage bar, so a non-integer fit must have been chosen.
assert_float(v._canvas_scale).override_failure_message(
"the reserved legend column must starve the 2x integer candidate at"
+ " this reference viewport, forcing the fractional fit"
).is_greater(1.0)