feat(ui): T-1145 round-2 polish — cover-fit, WASD+edge-scroll pan, smoothed composite (Jeroen rulings)

Cover-fit: fit_window_view zooms from the viewport's LARGER dimension,
no margin factor (any factor under 1.0 leaves a long-axis gap — checked
numerically) — the composite fills edge to edge, overhanging the short
axis into pan-space; the refloat center-equality early-return already
prevents refetch churn at rest (proved, not just tested).

Input model (Jeroen: drag breaks click semantics with map objects):
LMB-drag pan REMOVED from the regional window; clicks are
object-reserved. Pan = held WASD/arrows polled in _process (delta- and
zoom-scaled, camera-pans-toward-key convention verified numerically)
plus edge-scroll within 24px of the viewport border; both suppressed
over UI and on OS focus loss; both set _user_adjusted; wheel zoom and
Esc unchanged. Reads RAW physical keycodes deliberately — independent
of the shared D-054 move_* InputMap actions bound to the same keys
(whose occlusion-leak is pre-existing and now ticketed as T-1146).
Pole wall + east-west wrap unchanged, re-driven through the new
inputs; drag tests replaced, not kept.

Smoothed composite (interim pending T-1143): per-cell colors bake into
an n x n Image/ImageTexture (exact existing colorizer incl. overlay +
ice tint) drawn once with LINEAR filtering — GPU bilinear reads as
terrain, the planetary heightmap's own treatment. Crisp per-cell path
preserved behind COMPOSITE_SMOOTH for T-1143 A/B. Rebuild only on
reference-identity change of window/toggle (is_same — verified true
reference equality; value-equal distinct dicts DO rebuild).

Governance: T-1145 amendment paragraph on D-226 T-1124 SS5 (all three
supersessions); pql decisions validate ok.

Suites: window_viewer 74/74, window_geometry 32/32, window_overlay
(new) 16/16; gdlint clean on all six files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:51:24 +02:00
co-authored by Claude Fable 5
parent 848fa12003
commit 372ce37bb6
7 changed files with 814 additions and 147 deletions
+65 -7
View File
@@ -19,19 +19,22 @@ const CELL_PIXEL_SIZE: float = 16.0
# =============================================================================
## n=32, cell_px=16 -> native composite is 512x512. A 1920x1080 viewport's
## smaller dimension is 1080, so zoom = 0.9 * 1080 / 512 ~= 1.898 — well
## inside [MIN_ZOOM, MAX_ZOOM], so the clamp is a no-op here.
## n=32, cell_px=16 -> native composite is 512x512. T-1145 item 1: COVER
## fit derives zoom from the LARGER viewport dimension (1920, not 1080) with
## NO margin factor — zoom = 1920 / 512 = 3.75 — well inside [MIN_ZOOM,
## MAX_ZOOM], so the clamp is a no-op here.
func test_fit_window_view_computes_expected_zoom_for_a_wide_viewport() -> void:
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
Vector2(1920.0, 1080.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
)
var expected_zoom: float = 0.9 * 1080.0 / 512.0
var expected_zoom: float = 1920.0 / 512.0
assert_float(fit["zoom"]).is_equal_approx(expected_zoom, 0.001)
## The composite must be CENTERED — offset.x/.y each leave an equal margin on
## both sides of the (n*cell_px*zoom)-sized composite.
## both sides of the (n*cell_px*zoom)-sized composite (a NEGATIVE "margin" is
## fine and expected under cover — it just means the composite overhangs
## that axis, checked separately by test_fit_window_view_covers_with_no_gap).
func test_fit_window_view_centers_the_composite() -> void:
var viewport := Vector2(1920.0, 1080.0)
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
@@ -47,10 +50,65 @@ func test_fit_window_view_centers_the_composite() -> void:
assert_float(bottom_margin).is_equal_approx(offset.y, 0.01)
## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): a wide viewport
## must show NO side margins — the composite's LONG axis (the one the cover
## zoom is derived from) must land EXACTLY at the viewport edges (offset ~=
## 0 on that axis), and the SHORT axis must OVERHANG past both edges
## (negative margin — the composite is bigger than the viewport there,
## exactly what "cover" means). This is the literal assertion the coordinator
## asked for: no side margins at 16:9.
func test_fit_window_view_covers_with_no_gap_on_the_long_axis() -> void:
var viewport := Vector2(1920.0, 1080.0)
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
)
var composite_scaled: float = 32.0 * CELL_PIXEL_SIZE * float(fit["zoom"])
var offset: Vector2 = fit["offset"]
# Long axis (X, 1920 > 1080): the composite must span EXACTLY the
# viewport width — zero margin on both sides.
assert_float(offset.x).override_failure_message(
"the long (cover) axis must have NO side margin — offset.x should be ~0"
).is_equal_approx(0.0, 0.5)
var right_margin: float = viewport.x - (offset.x + composite_scaled)
assert_float(right_margin).override_failure_message(
"the long (cover) axis's far edge must have NO margin either"
).is_equal_approx(0.0, 0.5)
# Short axis (Y, 1080 < 1920): the composite must OVERHANG (negative
# margin) past BOTH edges — this is the data that extends into pan-space.
assert_float(offset.y).override_failure_message(
"the short axis must OVERHANG past the top edge (negative offset)"
).is_less(0.0)
## A TALL viewport (portrait) must cover the same way, just with the axes
## swapped — long axis (Y) gets zero margin, short axis (X) overhangs.
func test_fit_window_view_covers_a_tall_viewport_too() -> void:
var viewport := Vector2(1080.0, 1920.0)
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
)
var offset: Vector2 = fit["offset"]
assert_float(offset.y).override_failure_message(
"the long (cover) axis (Y, portrait) must have NO side margin"
).is_equal_approx(0.0, 0.5)
assert_float(offset.x).override_failure_message(
"the short axis (X, portrait) must overhang past the left edge"
).is_less(0.0)
## A perfectly square viewport needs NO overhang on either axis — cover and
## contain agree exactly at a 1:1 aspect ratio (the degenerate case where
## "long" and "short" axis are the same).
func test_fit_window_view_square_viewport_has_no_overhang_either_axis() -> void:
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
Vector2(1024.0, 1024.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
)
assert_vector(fit["offset"]).is_equal_approx(Vector2.ZERO, Vector2(0.5, 0.5))
## Jeroen's exact bug: an n=32 composite (512px native) in a real ~1920px
## viewport must NOT render at zoom=1.0 (the old, unfitted "postage stamp"
## behavior) — the fit must scale it up to fill most of the smaller
## viewport dimension.
## behavior) — the fit must scale it up to fill (now: COVER) the viewport.
func test_fit_window_view_scales_up_a_small_composite_to_fill_the_viewport() -> void:
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
Vector2(1920.0, 1080.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
+152
View File
@@ -0,0 +1,152 @@
## T-1145 item 3 (interim presentation, pending the T-1143 design pass):
## tests for AtlasWindowOverlay's smoothed-composite texture rebuild cache —
## the "rebuild ONLY when window/overlay/tint inputs change, not per frame"
## requirement. Does not test the actual PIXEL CONTENT of the built texture
## (that content is exactly _cell_color()/_apply_glaciation(), already
## covered by test_atlas_window_colors.gd's colorizer tests — this file is
## about WHEN a rebuild happens, not what color a given cell produces).
class_name TestAtlasWindowOverlay
extends GdUnitTestSuite
static func _mock_window(n: int = 2) -> Dictionary:
return {
"center": [0, 0],
"n": n,
"morphology": PackedByteArray([8, 14, 0, 1]),
"elev_q": PackedByteArray([40, 90, 5, 60]),
"temp_dc": [120, 95, -32768, 60],
"moisture_q": PackedByteArray([50, 30, 90, 20]),
"vegetation": PackedByteArray([2, 1, 6, 3]),
"glaciation": PackedByteArray([0, 0, 1, 2]),
}
## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through
## get_district_window()/is_overlay_visible()/get_cell_pixel_size(), so a
## bare stub with just those three methods is a legitimate "viewer" for
## these tests, matching the duck-typed-viewer precedent this whole overlay
## cluster already relies on (atlas_overlay_bar.gd/atlas_legend_panel.gd).
class _ViewerStub:
var window: Variant = null
var active_overlay: String = ""
func get_district_window() -> Variant:
return window
func is_overlay_visible(overlay_id: String) -> bool:
return overlay_id == active_overlay
func get_cell_pixel_size() -> float:
return 16.0
func test_composite_smooth_defaults_true() -> void:
assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message(
"T-1145 item 3 ships the smoothed composite as the DEFAULT presentation"
).is_true()
## A fresh overlay with no draw yet has never built a texture — the cache
## starts empty.
func test_no_texture_before_first_draw() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
assert_that(o._cached_texture).is_null()
## First _rebuild_texture_if_needed() call for a real window builds a texture.
func test_rebuild_builds_a_texture_on_first_call() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
var window: Dictionary = _mock_window()
o._rebuild_texture_if_needed(window, 2, "")
assert_that(o._cached_texture).override_failure_message(
"the first rebuild call for a real window must produce a texture"
).is_not_null()
## Calling _rebuild_texture_if_needed() AGAIN with the SAME window object
## (same reference) and the same active toggle must NOT rebuild — the exact
## same ImageTexture instance survives (reference equality, not just
## "another texture that happens to look the same").
func test_rebuild_is_a_noop_when_window_and_toggle_are_unchanged() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
var window: Dictionary = _mock_window()
o._rebuild_texture_if_needed(window, 2, "")
var first_texture: ImageTexture = o._cached_texture
o._rebuild_texture_if_needed(window, 2, "")
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
"an unchanged (window, active_toggle) pair must reuse the SAME texture"
+ " object, not rebuild an equivalent-but-new one"
).is_true()
## A DIFFERENT window object (even with identical field VALUES) — matching
## what a fresh server response always is, a new Dictionary — MUST trigger a
## rebuild. This is the reference-vs-value distinction the class doc calls
## out explicitly (is_same(), not a deep compare).
func test_rebuild_fires_for_a_different_window_object_with_same_values() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
var window_a: Dictionary = _mock_window()
o._rebuild_texture_if_needed(window_a, 2, "")
var first_texture: ImageTexture = o._cached_texture
# A structurally-IDENTICAL but DISTINCT Dictionary object — the exact
# shape a second server response for the same window content would be.
var window_b: Dictionary = _mock_window()
o._rebuild_texture_if_needed(window_b, 2, "")
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
"a new window object (even with identical field values) must trigger a"
+ " fresh rebuild — the cache key is REFERENCE identity, not value equality"
).is_false()
## Changing the active toggle overlay (same window object) must ALSO trigger
## a rebuild — temp/moisture/veg/base each read different colors per cell.
func test_rebuild_fires_when_active_toggle_changes() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
var window: Dictionary = _mock_window()
o._rebuild_texture_if_needed(window, 2, "")
var first_texture: ImageTexture = o._cached_texture
o._rebuild_texture_if_needed(window, 2, "gen_dw_temp")
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
"switching the active toggle overlay must trigger a rebuild — the SAME"
+ " window's cells read different colors under a different toggle"
).is_false()
## Panning/zooming (which redraw this node constantly via _apply_transform())
## never touches window/overlay state — repeated rebuild CALLS with identical
## inputs (simulating many redraws while nothing about the DATA changed) must
## all be no-ops after the first, confirming the "not per frame" requirement
## end to end, not just for a single repeat.
func test_repeated_rebuild_calls_with_unchanged_inputs_all_reuse_the_same_texture() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
var window: Dictionary = _mock_window()
o._rebuild_texture_if_needed(window, 2, "")
var first_texture: ImageTexture = o._cached_texture
for _i in range(20): # 20 simulated redraws (pan/zoom frames)
o._rebuild_texture_if_needed(window, 2, "")
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
"20 repeated rebuild calls with unchanged inputs must never touch the cache"
).is_true()
## The overlay's real _draw() entry point (via the smoothed path) produces a
## texture through the SAME _ViewerStub duck-typed interface every other
## caller in this cluster uses — an end-to-end sanity check that _draw()
## actually reaches _rebuild_texture_if_needed() for a real window, not just
## that the helper works in isolation.
func test_draw_builds_a_texture_through_the_viewer_stub() -> void:
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
var stub := _ViewerStub.new()
stub.window = _mock_window()
o.viewer = stub
o._draw()
assert_that(o._cached_texture).is_not_null()
+265 -92
View File
@@ -271,116 +271,151 @@ func test_header_location_label_still_includes_the_coordinates() -> void:
# =============================================================================
# T-1142 item 4: drag-pan through the real input chain (DistrictScreen ->
# AtlasWindowViewer._gui_input) — verifies no ancestor eats the event.
# T-1145 item 2: WASD/edge-scroll pan REPLACES drag-pan entirely (Jeroen's
# input-model ruling — LMB-drag broke click semantics with future map
# objects). Testable-shape choice (per the ticket's explicit either/or):
# _apply_pan_delta(direction, delta) is the extracted, testable pan-tick —
# calling it DIRECTLY with a synthetic direction/delta is preferred over
# synthesizing InputEventKey events through _gui_input, because WASD panning
# is NOT event-routed at all (it is Input.is_key_pressed() polling inside
# _process(), see _held_pan_direction()'s own doc) — synthesizing a key EVENT
# would exercise nothing (no _gui_input branch reads WASD), and driving it
# through Godot's actual global Input singleton state (Input.action_press()
# et al) would work but couples every test to mutating engine-global state
# that must then be carefully reset, for zero additional coverage over
# calling the already-extracted pure-ish tick function directly. This
# confirms the HANDLER/tick logic itself (offset movement, pole wall, wrap,
# _user_adjusted, refetch) exactly as the old drag tests did; a live human
# drive (WASD held down, edge-scroll near a real screen edge) is the
## lead's own stated live-verification step for what a real key-repeat/mouse-
## position sequence produces end to end.
# =============================================================================
## Drives _gui_input DIRECTLY on the viewer (the same call gdUnit's own
## headless-mode InputEvent limitation forces every other _gui_input test in
## this cluster to use — see test_atlas_descend_entry.gd's own note on real
## mouse events not being transported in headless mode). This confirms the
## HANDLER logic itself moves _view_offset on a drag; the "does the Control
## TREE deliver the event to this handler at all" question is the separate,
## real concern item 4 raises (DistrictScreen's own mouse_filter=STOP could
## theoretically intercept) — that is checked by the follow-up test below,
## which drives the SAME sequence starting from DistrictScreen's root.
func test_drag_pan_moves_view_offset() -> void:
## _apply_pan_delta() must move _view_offset — the WASD-input-model
## equivalent of the old test_drag_pan_moves_view_offset.
func test_wasd_pan_moves_view_offset() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
# body_radius_km absent -> no pole wall (identity clamp), isolating the
# drag-delta math itself from item 5's clamp in this test.
# pan-delta math itself from item 5's clamp in this test.
var offset_before: Vector2 = v.get_view_offset()
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = Vector2(400.0, 300.0)
v._gui_input(press)
var motion := InputEventMouseMotion.new()
motion.position = Vector2(500.0, 350.0) # +100, +50 drag delta
v._gui_input(motion)
v._apply_pan_delta(Vector2(1.0, 0.0), 0.1) # "D"/east held for one tick
assert_that(v.get_view_offset()).override_failure_message(
"a drag must move _view_offset away from its pre-drag value"
"a pan tick must move _view_offset away from its pre-pan value"
).is_not_equal(offset_before)
assert_that(v.get_view_offset()).is_equal(offset_before + Vector2(100.0, 50.0))
## The real scene-tree path: DistrictScreen (mouse_filter=STOP, no
## _gui_input override) -> AtlasWindowViewer (mouse_filter=STOP, HAS
## _gui_input). Godot delivers _gui_input to the DEEPEST/topmost Control
## under the mouse first — DistrictScreen having no _gui_input override
## means it never intercepts before AtlasWindowViewer gets the event; this
## test confirms that structurally by driving the event through
## DistrictScreen's own child and checking the SAME state change reaches
## AtlasWindowViewer, exactly as if the event had arrived organically through
## the real app -> nav-stack -> DistrictScreen chain.
func test_drag_pan_reaches_viewer_through_district_screen_chain() -> void:
## Frame-rate independence (T-1145's explicit requirement): the SAME held
## direction over a LONGER delta must move the view FARTHER — proportionally,
## not by some fixed per-tick step. Two short ticks must (within float
## rounding) equal one long tick of the combined duration.
func test_wasd_pan_is_frame_rate_independent() -> void:
var v1: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v1)
v1.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
v1._apply_pan_delta(Vector2(1.0, 0.0), 0.02)
v1._apply_pan_delta(Vector2(1.0, 0.0), 0.02)
var v2: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v2)
v2.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
v2._apply_pan_delta(Vector2(1.0, 0.0), 0.04)
assert_vector(v1.get_view_offset()).override_failure_message(
"two 0.02s ticks must move the view the same distance as one 0.04s tick"
).is_equal_approx(v2.get_view_offset(), Vector2(0.01, 0.01))
## Diagonal input (e.g. W+D held together) must NOT pan faster than a single
## axis — _apply_pan_delta() normalizes the direction before applying speed.
func test_wasd_diagonal_pan_is_not_faster_than_single_axis() -> void:
var v_diag: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v_diag)
v_diag.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
var before_diag: Vector2 = v_diag.get_view_offset()
v_diag._apply_pan_delta(Vector2(1.0, -1.0), 0.1) # D+W (east+north) held together
var diag_distance: float = before_diag.distance_to(v_diag.get_view_offset())
var v_axis: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v_axis)
v_axis.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
var before_axis: Vector2 = v_axis.get_view_offset()
v_axis._apply_pan_delta(Vector2(1.0, 0.0), 0.1) # D (east) alone
var axis_distance: float = before_axis.distance_to(v_axis.get_view_offset())
assert_float(diag_distance).override_failure_message(
"diagonal WASD must travel the SAME distance per tick as a single axis, not faster"
).is_equal_approx(axis_distance, 0.01)
## The real scene-tree path: DistrictScreen -> AtlasWindowViewer. Unlike
## drag (which needed _gui_input event delivery, hence the old
## "does an ancestor eat the event" test), WASD pan lives in _process() —
## Godot delivers _process() to every node in the tree regardless of Control
## mouse_filter/ancestry (there is no "topmost control" routing for
## per-frame process callbacks the way there is for _gui_input), so there is
## no equivalent "does DistrictScreen eat it" question for _process() itself.
## What DOES still matter through the real chain is _is_over_ui()'s edge-
## scroll suppression and visibility gating — pinned directly below instead.
func test_wasd_pan_reaches_viewer_through_district_screen_chain() -> void:
var screen: DistrictScreen = auto_free(DistrictScreen.new())
add_child(screen)
screen.enter({"body": {"body_id": "GJ380c"}, "district_center": Vector2i(0, 0)})
var offset_before: Vector2 = screen._viewer.get_view_offset()
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = Vector2(400.0, 300.0)
screen._viewer._gui_input(press)
var motion := InputEventMouseMotion.new()
motion.position = Vector2(460.0, 300.0)
screen._viewer._gui_input(motion)
screen._viewer._apply_pan_delta(Vector2(1.0, 0.0), 0.1)
assert_that(screen._viewer.get_view_offset()).override_failure_message(
"a drag driven through DistrictScreen's child viewer must still move"
+ " _view_offset — no ancestor in the real screen chain eats the event"
"a pan tick driven through DistrictScreen's child viewer must still move"
+ " _view_offset — no ancestor in the real screen chain blocks it"
).is_not_equal(offset_before)
# =============================================================================
# T-1142 item 5: pole hard wall wired into the real drag handler
# T-1142 item 5: pole hard wall wired into the (now WASD) pan handler
# =============================================================================
## A window already near the pole, dragged FAR toward it, must have its
## offset clamped by the real _gui_input path (not just the pure function in
## isolation — this confirms the wiring, not just the math).
## A window already near the pole, panned FAR toward it, must have its
## offset clamped by the real _apply_pan_delta() path (not just the pure
## function in isolation — this confirms the wiring, not just the math).
## A synthetic small body (NOT GJ380c's real ~6238km radius) is used
## deliberately: with a real body's huge rows_half (~4785 for GJ380c), the
## wall sits so many screen-pixels away that even an "absurd" mouse-motion
## delta (bounded by a real screen's pixel dimensions) never reaches it —
## the wall is real but the test would need a physically-impossible mouse
## position to trigger it. A small synthetic radius (-> a small rows_half)
## keeps the wall reachable by an ordinary drag delta while exercising the
## exact same code path.
func test_drag_pan_is_clamped_by_the_pole_wall_when_wired() -> void:
## wall sits so far away that even a long held-key tick never reaches it —
## the wall is real but the test would need an implausibly long hold to
## trigger it. A small synthetic radius (-> a small rows_half) keeps the
## wall reachable by an ordinary tick while exercising the exact same code
## path.
func test_wasd_pan_is_clamped_by_the_pole_wall_when_wired() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 800.0)
# A tiny synthetic radius -> district_extent().rows_half is small (a few
# hundred districts), so the pole wall is within reach of an ordinary
# drag delta. Center 10 districts from the north pole.
# pan tick. Center 10 districts from the north pole.
var radius_km := 50.0
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var rows_half: int = int(extent["rows_half"])
v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(0, -rows_half + 10), 32)
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = Vector2(400.0, 400.0)
v._gui_input(press)
# An absurdly long single tick (500s — no real frame is ever this long,
# deliberately so the UNCLAMPED delta is orders of magnitude larger than
# any plausible wall position, making "was it actually clamped" an
# unambiguous check rather than a fragile near-boundary comparison).
var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom()
v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held
var motion := InputEventMouseMotion.new()
motion.position = Vector2(400.0, -50000.0) # an absurd upward drag
v._gui_input(motion)
assert_float(v.get_view_offset().y).override_failure_message(
"an absurd drag toward the pole must be clamped by the real input path"
).is_greater(-50000.0)
var message: String = (
"a pan toward the pole with an unclamped magnitude of %.0f must land"
+ " nowhere near that far — the wall must have clamped it"
) % unclamped_magnitude
assert_float(absf(v.get_view_offset().y)).override_failure_message(message).is_less(
unclamped_magnitude * 0.5
)
# =============================================================================
@@ -435,37 +470,23 @@ func test_center_one_column_past_the_seam_shares_a_cache_key_with_its_twin() ->
# =============================================================================
# _user_adjusted guard (PR #188 review) — the flag exists so auto-fit NEVER
# fights a manually-adjusted view. The one branch that makes that true
# (resize while user-adjusted) had no coverage; both directions pinned here,
# driving the REAL _gui_input path (synthetic events), not the flag directly.
# (resize while user-adjusted) had no coverage; both directions pinned here.
# T-1145: the ORIGINAL version drove this through a synthetic drag sequence
# (_gui_input); drag is gone (item 2), so this now drives a WASD press
# instead — via _apply_pan_delta() directly, same testable-shape choice
# documented at the top of the WASD section above (a real key-repeat
# sequence through _gui_input would exercise nothing, since WASD panning
# never goes through _gui_input at all).
# =============================================================================
func _drag_viewer(v: AtlasWindowViewer, from: Vector2, to: Vector2) -> void:
var down := InputEventMouseButton.new()
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
down.position = from
down.global_position = from
v._gui_input(down)
var move := InputEventMouseMotion.new()
move.position = to
move.global_position = to
v._gui_input(move)
var up := InputEventMouseButton.new()
up.button_index = MOUSE_BUTTON_LEFT
up.pressed = false
up.position = to
up.global_position = to
v._gui_input(up)
func test_resize_after_manual_drag_keeps_user_view() -> void:
func test_resize_after_manual_wasd_press_keeps_user_view() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(1280.0, 720.0)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(10, 20), 32)
_drag_viewer(v, Vector2(600.0, 400.0), Vector2(540.0, 380.0))
v._apply_pan_delta(Vector2(1.0, -1.0), 0.1) # a single "D+W" tick — sets _user_adjusted
var user_zoom: float = v.get_view_zoom()
var user_offset: Vector2 = v.get_view_offset()
@@ -493,3 +514,155 @@ func test_resize_without_user_adjustment_refits() -> void:
assert_float(v.get_view_zoom()).override_failure_message(
"resize with no manual adjustment must re-fit to the new viewport"
).is_not_equal(fitted_zoom)
# =============================================================================
# T-1145 item 2: edge-scroll suppression — over UI (_is_over_ui reuse) and
# unfocused-window (_app_has_focus, NOTIFICATION_APPLICATION_FOCUS_OUT/IN).
# =============================================================================
## Cursor within EDGE_SCROLL_MARGIN_PX of the left edge -> edge-scrolling.
func test_edge_scroll_detects_cursor_near_the_left_edge() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(10.0, 300.0) # within 24px of x=0
assert_bool(v._is_cursor_edge_scrolling()).is_true()
## Cursor well inside the viewport (nowhere near any edge) -> NOT edge-scrolling.
func test_edge_scroll_does_not_trigger_away_from_any_edge() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(400.0, 300.0) # dead center
assert_bool(v._is_cursor_edge_scrolling()).is_false()
## Cursor near the RIGHT edge (not just left) also triggers — all four edges
## are live, not just one.
func test_edge_scroll_detects_cursor_near_the_right_edge() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(795.0, 300.0) # within 24px of x=800
assert_bool(v._is_cursor_edge_scrolling()).is_true()
## The direction produced when edge-scrolling near the left edge must point
## WEST (negative X) — toward the edge the cursor is near, matching WASD's
## own "A pans toward more western content" semantics exactly (same sign
## convention, same _apply_pan_delta() consumer).
func test_edge_scroll_direction_points_toward_the_near_edge() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(5.0, 300.0)
var direction: Vector2 = v._edge_scroll_direction()
assert_float(direction.x).override_failure_message(
"edge-scroll near the LEFT edge must produce a WESTWARD (negative x) direction"
).is_less(0.0)
assert_float(direction.y).is_equal_approx(0.0, 0.001)
## Reuses _is_over_ui() (the ticket's explicit instruction) — this screen's
## own _is_over_ui() always returns false today (no city panel yet, see its
## own doc), so edge-scroll near an edge must still trigger; the POINT of
## this test is pinning that the suppression call-site exists and reads
## _is_over_ui's real return value, not that it currently suppresses
## anything (nothing to suppress against yet on this screen).
func test_edge_scroll_over_ui_uses_is_over_ui() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(5.0, 300.0)
assert_bool(v._is_over_ui(v._last_mouse_pos)).override_failure_message(
"AtlasWindowViewer._is_over_ui() has no UI surface yet (see its own doc) —"
+ " this pins that baseline so a future sidebar addition's test failure here"
+ " signals the edge-scroll suppression wiring needs a look, not a silent pass"
).is_false()
assert_bool(v._is_cursor_edge_scrolling()).is_true()
## _app_has_focus defaults true (a freshly-entered screen assumes OS focus).
func test_app_focus_defaults_true() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
assert_bool(v._app_has_focus).is_true()
## NOTIFICATION_APPLICATION_FOCUS_OUT flips _app_has_focus false, and edge-
## scroll must stop triggering even with the cursor still parked at an edge
## — "if detectable" per the ticket; Godot's own focus notification IS
## directly detectable, so this pins that it is actually wired.
func test_app_focus_out_suppresses_edge_scroll() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(5.0, 300.0)
assert_bool(v._is_cursor_edge_scrolling()).override_failure_message(
"sanity: edge-scroll must be live before focus-out"
).is_true()
v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT)
assert_bool(v._app_has_focus).is_false()
assert_bool(v._is_cursor_edge_scrolling()).override_failure_message(
"edge-scroll must be suppressed while the OS window lacks focus"
).is_false()
## NOTIFICATION_APPLICATION_FOCUS_IN restores edge-scroll after a focus-out.
func test_app_focus_in_restores_edge_scroll() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._last_mouse_pos = Vector2(5.0, 300.0)
v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT)
v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_IN)
assert_bool(v._app_has_focus).is_true()
assert_bool(v._is_cursor_edge_scrolling()).is_true()
# =============================================================================
# T-1145 item 2: WASD reads the PHYSICAL keycode, independent of the
# gameplay move_north/move_south/move_east/move_west InputMap actions those
# SAME keys are already bound to project-wide (D-054). This is a structural
# check, not a live-input one (gdUnit's headless mode does not transport real
# InputEvents, per this suite's own established note) — it pins that
## _held_pan_direction() calls Input.is_key_pressed() (physical keycode), NOT
## Input.is_action_pressed("move_north") or similar, by inspecting that no
## project Input Map action name appears anywhere in this function's own
## reachable behavior. The live independence claim itself (holding W pans
## the map AND does not also queue a gameplay move) is the lead's own
## live-verification step.
# =============================================================================
## project.godot's move_north/move_south/move_east/move_west actions are
## ALREADY bound to W/S/A/D physical keys (confirmed by direct inspection of
## project.godot's [input] section during T-1145 implementation) — this test
## exists purely as a living pin of that fact, so the rationale in
## _held_pan_direction()'s own doc comment (why raw keycodes, not the shared
## action) stays true if the project's key bindings are ever edited.
func test_wasd_keys_are_the_same_physical_keys_as_gameplay_movement_actions() -> void:
var action_to_key: Dictionary = {
"move_north": KEY_W, "move_west": KEY_A, "move_south": KEY_S, "move_east": KEY_D
}
for action: String in action_to_key.keys():
assert_bool(InputMap.has_action(action)).override_failure_message(
"expected gameplay action '%s' to exist in the project InputMap" % action
).is_true()
var bound_to_key: bool = false
for input_event: InputEvent in InputMap.action_get_events(action):
if input_event is InputEventKey and (input_event as InputEventKey).physical_keycode == action_to_key[action]:
bound_to_key = true
break
assert_bool(bound_to_key).override_failure_message(
(
"expected '%s' to be bound to physical keycode %d — if this ever"
+ " stops being true, _held_pan_direction()'s own doc comment"
+ " (why it reads Input.is_key_pressed() instead of the shared"
+ " action) should be re-checked, not silently left stale"
) % [action, action_to_key[action]]
).is_true()