Jeroen: 'it scrolls out of screen automatically.' It did, with no input. _gui_input only fires while the pointer is over the Control, so _last_mouse_pos freezes at wherever it was last seen. Leaving the map ALWAYS means crossing an edge, so the frozen value is always inside the 24px edge margin — and the viewer went on believing the cursor was held there, panning forever. Moving the mouse elsewhere could not stop it, because 'elsewhere' generates no events this Control ever hears. NOTIFICATION_MOUSE_EXIT now resets to the same (-1,-1) sentinel the field is born with, making 'pointer is not over the map' and 'pointer has never been over the map' the same state. Neither should scroll, and there was already a test asserting the second case — the first had no equivalent. Considered and rejected: reading get_local_mouse_position() live instead of caching. It is arguably cleaner, but it cannot be injected in a headless test, so it would have traded a bug for the inability to prove the fix — and the existing edge-scroll suite drives _last_mouse_pos directly. This also explains the drift I had blamed on the capture harness (T-1236): same defect, and the harness was simply exercising it faithfully. Client suite 1833 / 1807 passed / 0 failed / 26 skipped. Co-Authored-By: Claude <noreply@anthropic.com>
1095 lines
51 KiB
GDScript
1095 lines
51 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)
|
|
|
|
## Isolated Tier-2/3 disk-cache root for every directly-constructed viewer
|
|
## (T-1193 first slice). The default user://atlas_cache/ is SHARED across the
|
|
## whole machine — every worktree's gate run, live capture driver, and real
|
|
## play session writes the same directory — and T-1183's disk-cache lookup
|
|
## short-circuits BEFORE test_mode's silent-no-op IPC, so a warm shared cache
|
|
## delivers real canvases into tests written against "nothing ever arrives"
|
|
## (2026-07-25: a concurrent live GJ380c Global capture flipped the two
|
|
## before-any-canvas tests in another worktree's gate run). In test_mode the
|
|
## isolated root stays empty forever: no canvas ever arrives, so the cache
|
|
## never writes.
|
|
const TEST_DISK_CACHE_ROOT := "user://test_step_canvas_viewer_cache/"
|
|
|
|
|
|
func _make_viewer() -> StepCanvasViewer:
|
|
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
|
v.disk_cache_root_override = TEST_DISK_CACHE_ROOT
|
|
return v
|
|
|
|
|
|
func test_enter_lands_on_the_global_opener() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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_hard_threshold() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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)
|
|
|
|
|
|
## Pair session 2026-07-26 — the SOFT threshold: a pan that crosses it must
|
|
## NOT re-float on the spot. It schedules the shared settle timer and leaves
|
|
## the view alone, so a held edge-scroll keeps panning the current canvas
|
|
## smoothly and issues ONE request when it stops, instead of a request plus a
|
|
## view snap on every frame past the threshold (the old behavior).
|
|
func test_soft_pan_drift_schedules_the_settle_instead_of_refloating_now() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.size = Vector2(800.0, 600.0)
|
|
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(512, 384), v.get_held_rung(), "")
|
|
v._view_offset = v._centered_view_offset()
|
|
|
|
# Drift to just past SOFT but well short of HARD.
|
|
var half: Vector2 = v._terrain_layer.get_footprint_px() * 0.5
|
|
var target: float = (
|
|
StepCanvasViewer.PAN_REFLOAT_SOFT_FRACTION + StepCanvasViewer.PAN_REFLOAT_HARD_FRACTION
|
|
) * 0.5
|
|
var screen_center: Vector2 = v.get_rect().size * 0.5
|
|
v._view_offset = screen_center - half + Vector2(half.x * target, 0.0)
|
|
|
|
var fraction: float = v._pan_drift_fraction()
|
|
assert_float(fraction).override_failure_message(
|
|
"test setup: drift must land between the soft and hard thresholds"
|
|
).is_between(StepCanvasViewer.PAN_REFLOAT_SOFT_FRACTION, StepCanvasViewer.PAN_REFLOAT_HARD_FRACTION)
|
|
|
|
var center_before: Vector2 = v._world_center
|
|
var offset_before: Vector2 = v._view_offset
|
|
v._maybe_refloat()
|
|
|
|
assert_that(v._world_center).override_failure_message(
|
|
"a soft-threshold pan must NOT re-float immediately"
|
|
).is_equal(center_before)
|
|
assert_that(v._view_offset).override_failure_message(
|
|
"a soft-threshold pan must not snap the view — the held canvas keeps panning"
|
|
).is_equal(offset_before)
|
|
assert_bool(v._refetch_settle_timer.is_stopped()).override_failure_message(
|
|
"a soft-threshold pan must schedule the shared settle timer"
|
|
).is_false()
|
|
|
|
|
|
## The inverse: panning back inside the soft threshold cancels the pending
|
|
## settle — the view no longer wants a different canvas, so the request that
|
|
## was about to go out must not.
|
|
func test_panning_back_inside_the_soft_threshold_cancels_the_pending_settle() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.size = Vector2(800.0, 600.0)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
v._scroll_rung(1, Vector2(400.0, 300.0))
|
|
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(512, 384), v.get_held_rung(), "")
|
|
|
|
v._refetch_settle_timer.start() # pretend a soft crossing already scheduled one
|
|
v._view_offset = v._centered_view_offset() # centred == zero drift
|
|
v._maybe_refloat()
|
|
|
|
assert_bool(v._refetch_settle_timer.is_stopped()).override_failure_message(
|
|
"drifting back inside the soft threshold must cancel the pending refetch"
|
|
).is_true()
|
|
|
|
|
|
## 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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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()
|
|
# Cache under the extent the viewer will actually REQUEST. This used to pass
|
|
# Vector2i.ZERO because Global's cache key collapsed the extent to a
|
|
# sentinel — correct while Global had exactly one possible size per body,
|
|
# wrong since the D-255 extent inversion made it viewport-sized (a stale
|
|
# sentinel entry answered every request forever, so a resize could never
|
|
# take effect). The centre stays ZERO: Global's canvas really is whole-body
|
|
# and origin-anchored, so the server genuinely ignores it.
|
|
req.get_cache().put(
|
|
v.get_body_id(),
|
|
"Global",
|
|
Vector2i.ZERO,
|
|
v._request_extent(),
|
|
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 = _make_viewer()
|
|
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, restated for the post-inversion ladder
|
|
## (D-255 amendment, pair session 2026-07-26). It used to be fixed by CAPPING
|
|
## Region's extent to the body's region grid; the inversion removes the defect
|
|
## at its source instead. Region's cell count is now plain viewport-fit, and
|
|
## what bounds it to the body is its GROUND extent: the shorter viewport axis
|
|
## spans exactly one region, so the canvas cannot wrap the body however large
|
|
## the window is. Asserting the ground extent is the honest version of what
|
|
## the old cap was reaching for.
|
|
func test_region_request_covers_exactly_one_region_on_the_short_axis() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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
|
|
var extent: Vector2i = v._request_extent()
|
|
|
|
# No longer capped to the body grid — the cell count is the window.
|
|
assert_that(extent).is_equal(StepCanvasTransport.viewport_fit_extent(v.size, "Region"))
|
|
|
|
# ...and the ground it covers is one region across the short axis, which
|
|
# is what actually prevents the sideways-repeat / pole-smear defect.
|
|
var spacing: float = StepCanvasTransport.spacing_for_rung(
|
|
"Region", extent, v.get_body_radius_km()
|
|
)
|
|
var short_axis_m: float = spacing * float(mini(extent.x, extent.y))
|
|
assert_float(short_axis_m).override_failure_message(
|
|
"Region must span exactly one region cell on the short axis, got %f m" % short_axis_m
|
|
).is_equal_approx(StepCanvasTransport.RUNG_EXTENT_M["Region"], 0.01)
|
|
|
|
# The whole body is 40,030 km around; this canvas must be a small fraction
|
|
# of it, not a wrap-around.
|
|
var circumference_m: float = TAU * 6371.0 * 1000.0
|
|
assert_bool(spacing * float(extent.x) < circumference_m).override_failure_message(
|
|
"a Region canvas must never span more ground than the body has"
|
|
).is_true()
|
|
|
|
|
|
## 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 = _make_viewer()
|
|
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),
|
|
## outliving the cap it was written for: the extent that becomes part of the
|
|
## cache key must be the SAME value the request actually carries — a request
|
|
## for "the same spot" must always resolve to the same key, never a key built
|
|
## from one extent and served under another. The inversion makes this MORE
|
|
## load-bearing, not less: the extent now also determines gridunit spacing, so
|
|
## a key/request divergence would mean a canvas served at the wrong scale
|
|
## rather than merely the wrong size.
|
|
func test_request_extent_matches_what_the_request_actually_sends() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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(StepCanvasTransport.viewport_fit_extent(v.size, "Region"))
|
|
|
|
# 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
|
|
# extent that keys the cache is the one the request carries.
|
|
var req: Variant = v.get_request()
|
|
var center: Vector2i = StepCanvasTransport.snap_to_gridunit(
|
|
v._world_center, "Region", extent_now, v.get_body_radius_km()
|
|
)
|
|
var canvas := TestStepCanvasViewer._synthetic_canvas(extent_now.x, extent_now.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, extent_now, 0)
|
|
).override_failure_message(
|
|
"a cache entry stored under the REQUESTED extent must be reachable"
|
|
+ " under that same 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 = _make_viewer()
|
|
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)
|
|
|
|
|
|
## Hoshe (review round 2): _global_body_extent must NOT leak across a body
|
|
## switch — land a Global extent for body A, enter() body B, and prove BOTH
|
|
## that the cap source itself reads back ZERO for the new body AND that a
|
|
## Region request for body B before ITS OWN Global echo lands goes out
|
|
## UNCAPPED (never silently capped by body A's leftover grid). The reset
|
|
## already exists at StepCanvasViewer.enter() ("a new body has its own
|
|
## region grid") — this proves it, through the real enter()/land/enter()
|
|
## sequence rather than asserting the field directly only.
|
|
func test_global_body_extent_resets_on_a_different_body_and_does_not_leak() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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._global_body_extent).override_failure_message(
|
|
"test setup: body A must actually have a landed cap source"
|
|
).is_equal(GJ1C_GLOBAL_EXTENT)
|
|
|
|
v.enter({"body_id": "T1189_extent_cap_body_b", "body_radius_km": 3000.0}, {})
|
|
|
|
assert_that(v._global_body_extent).override_failure_message(
|
|
"entering a DIFFERENT body must reset the cap source to ZERO — body"
|
|
+ " A's region grid must never leak into body B's requests"
|
|
).is_equal(Vector2i.ZERO)
|
|
|
|
v._scroll_rung(1, Vector2(960.0, 540.0)) # Region, for body B — no Global echo yet
|
|
var extent: Vector2i = v._request_extent()
|
|
var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "Region")
|
|
assert_that(extent).override_failure_message(
|
|
"body B's Region request, before body B's own Global echo has"
|
|
+ " landed, must go out UNCAPPED — never capped by body A's stale"
|
|
+ " leftover region-grid 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 = _make_viewer()
|
|
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 amendment 2026-07-25 texel-exactness: the `_canvas.scale` value
|
|
## itself is NOT required to be a whole number (9/5 = 1.8 is entirely
|
|
## legitimate) — what MUST be exact is the resulting on-screen
|
|
## pixels-per-gridunit ratio. Verified end-to-end through the real viewer
|
|
## wiring: `_canvas_scale * base_display_ratio` (the rung's own display
|
|
## ratio, 5.0 for Global) must land on an exact integer, for both the
|
|
## legend-reserved 1920x1080 case (R=9, scale=1.8) AND the 4K case (R=20,
|
|
## scale=4.0) — the SAME formula, no separate coverage-threshold branch.
|
|
func test_global_canvas_scale_yields_an_exact_integer_pixels_per_gridunit_ratio() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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._canvas.scale).is_equal(Vector2(v._canvas_scale, v._canvas_scale))
|
|
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global")
|
|
var effective_px_per_gridunit: float = v._canvas_scale * base_ratio
|
|
assert_float(effective_px_per_gridunit).override_failure_message(
|
|
"the FINAL on-screen pixels-per-gridunit ratio must be an exact"
|
|
+ " integer even when the _canvas.scale multiplier itself is not"
|
|
).is_equal_approx(roundf(effective_px_per_gridunit), 0.001)
|
|
# The lead's own cited reference number for this exact scenario.
|
|
assert_float(effective_px_per_gridunit).is_equal_approx(9.0, 0.001)
|
|
|
|
|
|
## Same invariant at a large (4K-class) viewport, where the integer ratio
|
|
## (20) happens to make the _canvas.scale multiplier itself a whole number
|
|
## too (20/5 = 4.0) — confirms the 1080p case above isn't a coincidence of
|
|
## a small viewport, just the same formula at a different achievable ratio.
|
|
func test_global_canvas_scale_at_4k_also_yields_an_exact_integer_ratio() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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)
|
|
|
|
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global")
|
|
var effective_px_per_gridunit: float = v._canvas_scale * base_ratio
|
|
assert_float(effective_px_per_gridunit).is_equal_approx(20.0, 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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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 = _make_viewer()
|
|
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)
|
|
|
|
|
|
## EYEBALL REGRESSION (pair session 2026-07-26, Lendel): the Atlas opened on a
|
|
## Global map that was literally two cells — one green, one blue — stretched
|
|
## across the window, reporting 19,598 km/gridunit, exactly half the body's
|
|
## circumference. Cause: enter() fires its first request BEFORE this Control is
|
|
## laid out, and the not-laid-out size is not always exactly ZERO, so a few
|
|
## stray pixels sailed past the `== Vector2.ZERO` guard and asked for a 2x2
|
|
## gridunit canvas. Harmless while Global ignored the requested extent and took
|
|
## its cell counts from the body's region grid; load-bearing the moment the
|
|
## D-255 extent inversion made the request the canvas size.
|
|
func test_request_extent_ignores_a_not_yet_laid_out_viewport() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
for degenerate in [Vector2.ZERO, Vector2(4.0, 4.0), Vector2(1920.0, 2.0)]:
|
|
v.size = degenerate
|
|
var extent: Vector2i = v._request_extent()
|
|
# The fallback is sized to the DRAWABLE area, so Global's reserved
|
|
# legend column comes off the width first — the request must match what
|
|
# can actually be drawn, or the Global integer fit drops a whole step
|
|
# and the map renders at half size in a window with room to spare.
|
|
var drawable := Vector2(
|
|
StepCanvasViewer.FALLBACK_VIEWPORT_PX.x - StepCanvasTransport.LEGEND_COLUMN_PX,
|
|
StepCanvasViewer.FALLBACK_VIEWPORT_PX.y
|
|
)
|
|
# Global sizes via global_fill_extent (2:1, chosen so the display ratio
|
|
# divides exactly), not the generic viewport fit.
|
|
var expected: Vector2i = StepCanvasTransport.global_fill_extent(drawable)
|
|
assert_that(extent).override_failure_message(
|
|
"a %s viewport must fall back, not be taken literally — got %s" % [degenerate, extent]
|
|
).is_equal(expected)
|
|
|
|
|
|
## ...and the second half of the same bug: Global was excluded from the refetch
|
|
## settle entirely, so a canvas born at the wrong size could never heal however
|
|
## the window was resized. Global must take the SIZE refit (it is viewport-sized
|
|
## like every rung now) but never the pan re-float (its canvas is whole-body and
|
|
## origin-anchored — the server ignores `center` for Global), which _refloat_now()
|
|
## would betray by zeroing _view_offset.
|
|
func test_global_takes_the_size_refit_but_never_the_pan_refloat() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
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)
|
|
|
|
# Drift the view far enough that a fixed rung would hard re-float.
|
|
v._view_offset = Vector2(-100_000.0, -100_000.0)
|
|
v._on_refetch_settle()
|
|
|
|
# _refloat_now() would re-centre the request on whatever world point sits
|
|
# under the viewport centre; Global's canvas is origin-anchored and the
|
|
# server ignores `center` for it, so the world centre must not move.
|
|
# (_view_offset is NOT the probe here — _recompute_canvas_transform()
|
|
# legitimately re-centres it on any canvas adoption.)
|
|
assert_that(v._world_center).override_failure_message(
|
|
"Global has no centre to re-float to — _world_center must stay at the origin"
|
|
).is_equal(Vector2.ZERO)
|
|
|
|
|
|
## Hoshe (review round 2): a narrow viewport where GJ1c's canvas exceeds
|
|
## the available width on the gridunit lattice itself (177 gridunits >
|
|
## available px after the legend column is reserved) used to make the OLD
|
|
## fractional-fit branch return a sub-1x scale (0.39x) — a real downscale
|
|
## below native resolution, violating "never below native". The integer
|
|
## px/gridunit ratio floors at 1 instead: `_canvas_scale` must never drop
|
|
## under 1.0, end-to-end through the real viewer wiring, not just the pure
|
|
## transport function this mirrors.
|
|
func test_global_canvas_scale_never_drops_below_native_on_a_narrow_viewport() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.size = Vector2(348.0 + StepCanvasTransport.LEGEND_COLUMN_PX, 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 base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global")
|
|
assert_float(v._canvas_scale * base_ratio).override_failure_message(
|
|
"the effective pixels-per-gridunit ratio must floor at 1 (native),"
|
|
+ " never a sub-1x downscale, even on a viewport this narrow"
|
|
).is_equal_approx(1.0, 0.001)
|
|
|
|
|
|
# =============================================================================
|
|
# T-971 (AtlasAgentInterface): jump_to() — the fixed-center revisit seam —
|
|
# and get_current_canvas_summary().
|
|
# =============================================================================
|
|
|
|
|
|
## jump_to() must set the SAME held rung/world_center a cursor-anchored
|
|
## scroll to that same spot would land on — this is the "same cache key"
|
|
## guarantee AtlasAgentInterface's jump_to_center intent depends on
|
|
## (verified end-to-end, through act(), in test_atlas_agent_interface.gd;
|
|
## this is the narrower unit-level check directly against the viewer).
|
|
func test_jump_to_sets_held_rung_and_world_center() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
|
|
v.jump_to(Vector2(500.0, -250.0), StepCanvasTransport.RUNG_QUARTER)
|
|
|
|
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_QUARTER)
|
|
assert_that(v.get_world_center()).is_equal(Vector2(500.0, -250.0))
|
|
|
|
|
|
## Omitting `rung` keeps whatever rung is currently held — the "revisit
|
|
## within the same rung" common case shouldn't require repeating it.
|
|
func test_jump_to_keeps_current_rung_when_omitted() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
v._scroll_rung(1, Vector2(400.0, 300.0)) # Region
|
|
|
|
v.jump_to(Vector2(10.0, 20.0))
|
|
|
|
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION)
|
|
|
|
|
|
## Jumping to Global always forces world_center to ZERO — Global has no
|
|
## panned-center concept (mirrors _scroll_rung()'s own Global-rung handling).
|
|
func test_jump_to_global_forces_world_center_to_zero() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
v._scroll_rung(1, Vector2(400.0, 300.0)) # Region
|
|
|
|
v.jump_to(Vector2(777.0, 888.0), StepCanvasTransport.RUNG_GLOBAL)
|
|
|
|
assert_that(v.get_world_center()).is_equal(Vector2.ZERO)
|
|
|
|
|
|
func test_jump_to_unrecognized_rung_is_a_no_op() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
var rung_before: String = v.get_held_rung()
|
|
var center_before: Vector2 = v.get_world_center()
|
|
|
|
v.jump_to(Vector2(1.0, 2.0), "NotARealRung")
|
|
|
|
assert_str(v.get_held_rung()).is_equal(rung_before)
|
|
assert_that(v.get_world_center()).is_equal(center_before)
|
|
|
|
|
|
func test_get_current_canvas_summary_before_any_canvas_arrives() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
|
|
var summary: Dictionary = v.get_current_canvas_summary()
|
|
|
|
assert_bool(summary.get("has_canvas", true)).is_false()
|
|
assert_str(summary.get("rung", "")).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
|
|
|
|
|
## The T-1157-inventory-relevant correctness check: course/cliff/settlement
|
|
## counts must match a fixture canvas exactly, including the per-class
|
|
## course histogram and settlement id dedup (mirrors
|
|
## StepCanvasAnnotationLayer._draw_settlements()'s own dedup discipline —
|
|
## covering the same cell id twice must not double-count).
|
|
func test_get_current_canvas_summary_counts_match_a_fixture_canvas() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
v._scroll_rung(1, Vector2(400.0, 300.0)) # Region
|
|
|
|
var canvas: Dictionary = _synthetic_canvas(4, 4)
|
|
canvas["courses"] = [
|
|
{"class": 0, "points": [[0.0, 0.0], [1.0, 1.0]]},
|
|
{"class": 0, "points": [[2.0, 2.0], [3.0, 3.0]]},
|
|
{"class": 2, "points": [[4.0, 4.0], [5.0, 5.0]]},
|
|
]
|
|
canvas["cliffs"] = [{"a": 1}, {"b": 2}]
|
|
# 4x4 grid; ids 5 and 5 (repeat, same settlement footprint) dedup to one,
|
|
# id 9 is a second distinct settlement, 0 is "no settlement" and ignored.
|
|
canvas["settlement_id"] = [
|
|
0, 5, 5, 0,
|
|
0, 0, 0, 0,
|
|
9, 0, 0, 0,
|
|
0, 0, 0, 0,
|
|
]
|
|
v._on_canvas_ready(canvas)
|
|
|
|
var summary: Dictionary = v.get_current_canvas_summary()
|
|
|
|
assert_bool(summary.get("has_canvas", false)).is_true()
|
|
assert_int(summary.get("canvas_width", 0)).is_equal(4)
|
|
assert_int(summary.get("canvas_height", 0)).is_equal(4)
|
|
assert_int(summary.get("course_count", 0)).is_equal(3)
|
|
assert_int(summary.get("cliff_count", 0)).is_equal(2)
|
|
assert_int(summary.get("settlement_count", 0)).is_equal(2)
|
|
var by_class: Dictionary = summary.get("course_count_by_class", {})
|
|
assert_int(int(by_class.get(0, 0))).is_equal(2)
|
|
assert_int(int(by_class.get(2, 0))).is_equal(1)
|
|
|
|
|
|
# =============================================================================
|
|
# T-1197 PR #217 review (Hoshe): header-panel-vs-legend-panel vertical
|
|
# non-overlap — the exact regression this review round caught. Mirrors the
|
|
# T-1192 precedent above (test_global_canvas_left_edge_never_overlaps_the_legend_column,
|
|
# line ~674): a geometric non-overlap invariant against the REAL viewer
|
|
# wiring, not a hand-computed expected pixel value that could silently drift
|
|
# out of sync with the production layout the same way the old hardcoded
|
|
# Vector2(PANEL_MARGIN, 60.0) drifted out of sync with the header's real
|
|
# grown footprint.
|
|
# =============================================================================
|
|
|
|
|
|
## The screen header panel and the legend panel must never vertically
|
|
## overlap: the legend's TOP edge (position.y) must be at or below the
|
|
## header's BOTTOM edge (position.y + size.y). Before the T-1197 PR #217 fix,
|
|
## step_canvas_legend.gd's reposition() hardcoded Y=60.0 — a constant tuned
|
|
## for the OLD bare-ImplantHeader footprint — so once the header grew its own
|
|
## ImplantPanel wrapper (border + content margins), the legend's fixed Y sat
|
|
## INSIDE the header panel's new, taller footprint: the two fused into one
|
|
## unbroken double-height box with zero terrain gap between them (pixel-
|
|
## proven independently by both PR #217 reviewers). This test pins the
|
|
## invariant directly against the real _screen_header_panel/_legend_panel
|
|
## Controls the production layout builds, not a copy of the geometry math.
|
|
func test_legend_panel_never_overlaps_the_header_panel_vertically() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
|
|
# Both panels are manually positioned (implant_panel.gd's own doc: "not
|
|
# itself inside a parent Container, so nothing else forces a re-measure"),
|
|
# and reset_to_content_size()/reposition() are deferred — award one idle
|
|
# frame so the REAL settled sizes are in place before asserting, exactly
|
|
# like the deferred-resize idiom both panels already rely on in production
|
|
# (see _build_screen_header()'s own call to reset_to_content_size(), and
|
|
# _ready()'s own deferred reposition() call added alongside this test).
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
|
|
var header_top: float = v._screen_header_panel.position.y
|
|
var header_bottom: float = header_top + v._screen_header_panel.size.y
|
|
var legend_top: float = v._legend_panel.position.y
|
|
|
|
assert_float(legend_top).override_failure_message(
|
|
(
|
|
"the legend panel's top edge (y=%.1f) must be AT OR BELOW the header"
|
|
+ " panel's measured bottom edge (y=%.1f) — a smaller value means the"
|
|
+ " two panels overlap/fuse into one box, the exact PR #217 regression"
|
|
)
|
|
% [legend_top, header_bottom]
|
|
).is_greater_equal(header_bottom - 0.01)
|
|
|
|
|
|
## The gap must be a REAL, visible gap — not just "touching at exactly the
|
|
## same pixel" (which would still satisfy >= but reads as fused on screen).
|
|
## Pins the fixed HEADER_LEGEND_GAP_PX constant is actually being applied,
|
|
## not merely that overlap happens to be avoided by coincidence of content
|
|
## size on this particular test body.
|
|
func test_legend_panel_leaves_a_real_gap_below_the_header_panel() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
|
|
var header_bottom: float = v._screen_header_panel.position.y + v._screen_header_panel.size.y
|
|
var legend_top: float = v._legend_panel.position.y
|
|
var gap: float = legend_top - header_bottom
|
|
|
|
assert_float(gap).override_failure_message(
|
|
(
|
|
"expected a visible gap of at least %.1fpx between the header panel's"
|
|
+ " bottom (y=%.1f) and the legend panel's top (y=%.1f), got %.1fpx —"
|
|
+ " panels that merely touch still read as one fused box on screen"
|
|
)
|
|
% [v.get_header_legend_gap_px(), header_bottom, legend_top, gap]
|
|
).is_greater_equal(v.get_header_legend_gap_px() - 0.01)
|
|
|
|
## The "it scrolls out of screen automatically" defect (Jeroen, 2026-07-27).
|
|
## _gui_input only fires while the pointer is over the Control, so the cached
|
|
## mouse position freezes wherever it was last seen — and leaving the map
|
|
## ALWAYS means crossing an edge, so the frozen value is always inside the
|
|
## edge margin. The viewer then panned forever with no input, and moving the
|
|
## mouse elsewhere could not stop it because "elsewhere" produces no events
|
|
## this Control hears. NOTIFICATION_MOUSE_EXIT must reset to the same sentinel
|
|
## the field is born with.
|
|
func test_edge_scroll_stops_when_the_pointer_leaves_the_control() -> void:
|
|
var v: StepCanvasViewer = _make_viewer()
|
|
add_child(v)
|
|
v.size = Vector2(800.0, 600.0)
|
|
v._app_has_focus = true
|
|
|
|
# Pointer parked in the left edge margin — scrolling, correctly.
|
|
v._last_mouse_pos = Vector2(4.0, 300.0)
|
|
assert_bool(v._is_cursor_edge_scrolling()).override_failure_message(
|
|
"a cursor inside the edge margin should edge-scroll"
|
|
).is_true()
|
|
|
|
# Pointer leaves the Control entirely.
|
|
v._notification(Control.NOTIFICATION_MOUSE_EXIT)
|
|
|
|
assert_bool(v._is_cursor_edge_scrolling()).override_failure_message(
|
|
"edge scroll must STOP once the pointer leaves — otherwise the map"
|
|
+ " pans off the world forever with no input"
|
|
).is_false()
|