RVR-on-by-default is the single most player-visible behavior wave 1 ships (rivers appear with no toggle hunt) and was only exercised by live captures; gen_basins' default was pinned incidentally inside the toggle-redraw spy test and gen_attractors not at all. One named test now asserts all three fresh-construction defaults per Araminta's ruling (RVR on, BAS off, ATR off). Revert-verified: flipping the gen_rivers init line fails exactly this test by name (1 failure total in the 78-test suite). Suite 78/78; gdlint clean. Tickets: T-1156 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
778 lines
36 KiB
GDScript
778 lines
36 KiB
GDScript
## T-1138 (D-226 T-1124 amendment §1-§5): tests for AtlasWindowViewer + its
|
|
## companion request/cache orchestration (atlas_window_request.gd) — pure
|
|
## logic against hand-built AtlasLayerResponse-shaped dicts, matching the
|
|
## ticket's "unit tests against hand-built response dicts" instruction. Live
|
|
## end-to-end verification against a real spawned server is separate
|
|
## (companion-run evidence, not gdUnit — this file never touches SimBridge's
|
|
## live-mode path, only the response-handling/cache/overlay logic that path
|
|
## eventually feeds).
|
|
class_name TestAtlasWindowViewer
|
|
extends GdUnitTestSuite
|
|
|
|
# atlas_window_request.gd has no class_name (review #8 precedent throughout
|
|
# this cluster) — preloaded once here, not re-load()ed per test (gdlint
|
|
# duplicated-load).
|
|
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
|
# T-1142: district_extent()/canonicalize_district_center() — used to derive
|
|
# real (cols/rows_half) bounds for the wrap/pole-wall tests below.
|
|
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
|
# T-1156 wave 1 round 3: AtlasWindowNatureOverlay has no class_name (matching
|
|
# atlas_overlay_bar.gd/atlas_window_request.gd's own no-class_name precedent,
|
|
# review #8) — subclassing it (the _CountingNatureOverlay spy below) needs
|
|
# the preloaded script's PATH via `extends`, not a global class name.
|
|
const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd")
|
|
|
|
|
|
## Build a hand-authored DistrictWindowLayer dict (n=2, matching the shape
|
|
## district_grid/region_grid fixtures already use elsewhere in this suite).
|
|
static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary:
|
|
return {
|
|
"center": [center.x, center.y],
|
|
"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]),
|
|
}
|
|
|
|
|
|
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
|
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
|
|
|
|
|
# =============================================================================
|
|
# AtlasWindowViewer — entry + overlay defs
|
|
# =============================================================================
|
|
|
|
|
|
func test_enter_with_no_response_leaves_window_null_and_pending() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20))
|
|
assert_that(v.get_district_window()).is_null()
|
|
|
|
|
|
## Feeding a matching Ready response (via the SAME SimBridge.atlas_layers_received
|
|
## routing path the viewer subscribes to in _ready()) must populate the window.
|
|
func test_enter_then_matching_response_populates_window() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
|
|
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
|
|
assert_that(v.get_district_window()).is_equal(window)
|
|
|
|
|
|
## A response for a DIFFERENT body must not populate the window — the
|
|
## body_id scoping AtlasWindowRequest.on_response() checks.
|
|
func test_response_for_different_body_is_ignored() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
|
|
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window))
|
|
|
|
assert_that(v.get_district_window()).is_null()
|
|
|
|
|
|
## A response whose echoed (center, n) does NOT match what was last asked for
|
|
## is stale — §2's race-condition guard. Simulates a superseded-by-a-later-pan
|
|
## response arriving after the fact.
|
|
func test_response_with_mismatched_echo_is_discarded_as_stale() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
|
|
var stale_window: Dictionary = _mock_window(Vector2i(99, 99), 2) # wrong center
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window))
|
|
|
|
assert_that(v.get_district_window()).is_null()
|
|
|
|
|
|
## §1: an as-yet-underived window rides as `district_window: None` inside a
|
|
## Ready response — this is NOT an error, the viewer just keeps waiting
|
|
## (get_district_window() stays null, no crash, no window content shown).
|
|
func test_ready_response_with_null_district_window_keeps_waiting() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", null))
|
|
assert_that(v.get_district_window()).is_null()
|
|
|
|
|
|
func test_overlay_defs_include_the_three_toggle_ids() -> void:
|
|
var ids: Array = []
|
|
for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS:
|
|
ids.append(d["id"])
|
|
assert_that(ids).contains(["gen_dw_temp", "gen_dw_moisture", "gen_dw_veg"])
|
|
|
|
|
|
## Glaciation is explicitly NOT a toggle id (§5: "an always-on modifier, not
|
|
## a toggle") — a regression here would silently re-introduce it as a switch.
|
|
func test_overlay_defs_do_not_include_glaciation() -> void:
|
|
var ids: Array = []
|
|
for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS:
|
|
ids.append(d["id"])
|
|
assert_that(ids).not_contains(["gen_dw_glaciation", "gen_dw_ice"])
|
|
|
|
|
|
func test_set_overlay_visible_toggles_and_is_overlay_visible_reflects_it() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
assert_bool(v.is_overlay_visible("gen_dw_temp")).is_false()
|
|
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_noop() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.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()
|
|
|
|
|
|
## PR #195 review (Hoshe): named default-visibility pins for the T-1156
|
|
## nature overlays at fresh-viewer construction, per Araminta's ruling —
|
|
## RVR ON (rivers are load-bearing geography, a first-time Atlas viewer sees
|
|
## them without hunting for a toggle: the single most player-visible behavior
|
|
## the feature ships), BAS and ATR OFF (secondary/dev-facing analytical
|
|
## layers, opt-in). Without these, a refactor of the _ready() visibility-init
|
|
## loop could silently flip the defaults and only a live capture would
|
|
## notice — gen_basins was previously covered only incidentally by the
|
|
## toggle-redraw spy test above.
|
|
func test_nature_overlay_defaults_rivers_on_basins_and_attractors_off() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
assert_bool(v.is_overlay_visible("gen_rivers")).override_failure_message(
|
|
"gen_rivers must default ON (Araminta's RVR-on ruling, T-1156 wave 1)"
|
|
).is_true()
|
|
assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message(
|
|
"gen_basins must default OFF (opt-in analytical layer)"
|
|
).is_false()
|
|
assert_bool(v.is_overlay_visible("gen_attractors")).override_failure_message(
|
|
"gen_attractors must default OFF (dev-facing detail, opt-in)"
|
|
).is_false()
|
|
|
|
|
|
## Counts real _draw() invocations on AtlasWindowNatureOverlay — CanvasItem
|
|
## exposes no public "is a redraw pending" query in this Godot version
|
|
## (confirmed directly: is_queued_for_redraw() does not exist on Node2D here
|
|
## — an earlier version of this test assumed it did and failed with
|
|
## "Invalid call. Nonexistent function"), so the only reliable signal that
|
|
## queue_redraw() actually had an effect is the engine calling _draw() again
|
|
## on a subsequent frame. Matches test_atlas_cold_start.gd's own
|
|
## _CountingOverlay precedent exactly (same file's own doc: "the only
|
|
## reliable signal... is the engine calling _draw() again") — subclasses the
|
|
## REAL AtlasWindowNatureOverlay so drawing still runs through genuine
|
|
## production code, this spy only adds counting.
|
|
class _CountingNatureOverlay extends AtlasWindowNatureOverlay:
|
|
var draw_count := 0
|
|
|
|
func _draw() -> void:
|
|
draw_count += 1
|
|
super._draw()
|
|
|
|
|
|
## Coordinator live-eyeball round 3 (2026-07-23): R1/R2 captures were
|
|
## byte-identical because a scratch drive script called
|
|
## set_overlay_visible("BAS", true) — the button LABEL, not the overlay id
|
|
## ("gen_basins") — which set_overlay_visible()'s own `not
|
|
## _overlay_visibility.has(overlay_id): push_warning(...); return` guard
|
|
## silently no-ops on. Real callers (atlas_overlay_bar.gd's
|
|
## _on_toggle_changed()) always pass def["id"], never the label, so product
|
|
## code was never actually broken — but this pins the EXACT gate the
|
|
## coordinator asked to verify: toggling gen_basins via the real viewer API
|
|
## must (a) flip is_overlay_visible("gen_basins") — the nature overlay's OWN
|
|
## draw gate, read live via viewer.is_overlay_visible() at _draw() time, not
|
|
## a stale copy — AND (b) actually cause the NATURE overlay (not just the
|
|
## terrain overlay/viewer) to redraw on the next frame, proven by swapping in
|
|
## a _draw()-counting spy (matching test_atlas_cold_start.gd's
|
|
## _CountingOverlay pattern) and confirming draw_count advances past a
|
|
## settled baseline after the toggle, with no other gesture.
|
|
func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message(
|
|
"gen_basins must default to OFF (Araminta's ruling — BAS defaults off)"
|
|
).is_false()
|
|
|
|
# Swap in the counting spy (matching _CountingOverlay's own swap-after-
|
|
# construction shape) so entry's own queue_redraw() calls don't pollute
|
|
# the baseline, then let it settle before touching the toggle.
|
|
var spy := _CountingNatureOverlay.new(v)
|
|
v._nature_overlay.queue_free()
|
|
v._nature_overlay = spy
|
|
v._canvas.add_child(spy)
|
|
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
var baseline: int = spy.draw_count
|
|
assert_int(baseline).override_failure_message(
|
|
"sanity: the spy must have drawn at least once before the toggle, or"
|
|
+ " this test can't distinguish 'redrawn BY the toggle' from 'never"
|
|
+ " drawn at all'"
|
|
).is_greater(0)
|
|
|
|
v.set_overlay_visible("gen_basins", true)
|
|
await get_tree().process_frame
|
|
|
|
assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message(
|
|
"the toggle must flip the draw gate is_overlay_visible() reads live"
|
|
).is_true()
|
|
assert_int(spy.draw_count).override_failure_message(
|
|
"the toggle must queue_redraw() the NATURE overlay specifically —"
|
|
+ " queue_redraw() on the viewer/terrain overlay alone leaves the"
|
|
+ " nature node's last frame cached (a Node2D child does not redraw"
|
|
+ " because its sibling did) — draw_count must have advanced past the"
|
|
+ " baseline (%d)" % baseline
|
|
).is_greater(baseline)
|
|
|
|
|
|
# =============================================================================
|
|
# T-1120 capture-API parity (the ticket's explicit note: must survive here too)
|
|
# =============================================================================
|
|
|
|
|
|
func test_set_view_and_getters_round_trip() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.set_view(2.5, Vector2(30.0, -10.0))
|
|
assert_that(v.get_view_zoom()).is_equal_approx(2.5, 0.001)
|
|
assert_that(v.get_view_offset()).is_equal(Vector2(30.0, -10.0))
|
|
|
|
|
|
## T-1153: MIN_ZOOM widened to 0.0005 (from the pre-ladder 0.5) so a
|
|
## gas-giant-scale body's enter_orbital() fit zoom is never itself clamped —
|
|
## see MIN_ZOOM's own doc. Values here are chosen well outside the new wide
|
|
## range on both ends, not the old range's boundary values.
|
|
func test_set_view_clamps_to_min_max_zoom() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.set_view(0.0000001, Vector2.ZERO)
|
|
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.0001)
|
|
v.set_view(1000.0, Vector2.ZERO)
|
|
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MAX_ZOOM, 0.001)
|
|
|
|
|
|
# =============================================================================
|
|
# AtlasWindowRequest — cache reuse (§4's Esc-then-re-enter / pan-back hit)
|
|
# =============================================================================
|
|
|
|
|
|
func test_window_request_cache_hit_emits_synchronously_no_pending() -> void:
|
|
var owner_stub := RefCounted.new()
|
|
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
|
add_child(req)
|
|
|
|
# Prime the cache directly (bypassing the network path) — the ticket's
|
|
# own instruction: unit test against hand-built response dicts.
|
|
req.get_cache().put("GJ380c", Vector2i(1, 1), 2, _mock_window(Vector2i(1, 1), 2))
|
|
|
|
var received: Array = []
|
|
req.window_ready.connect(func(w: Dictionary) -> void: received.append(w))
|
|
req.request_now("GJ380c", Vector2i(1, 1), 2)
|
|
|
|
assert_int(received.size()).is_equal(1)
|
|
assert_bool(req.is_pending()).override_failure_message(
|
|
"a cache hit must never leave the request pending"
|
|
).is_false()
|
|
|
|
|
|
func test_window_request_cache_miss_leaves_pending_true() -> void:
|
|
var owner_stub := RefCounted.new()
|
|
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
|
add_child(req)
|
|
req.request_now("GJ380c", Vector2i(5, 5), 2)
|
|
assert_bool(req.is_pending()).is_true()
|
|
|
|
|
|
## on_response() with a matching Ready+window response resolves the pending
|
|
## request AND populates the cache — verified by a second request_now() call
|
|
## for the same (center, n) becoming a cache hit with zero additional pending.
|
|
func test_on_response_resolves_and_populates_cache_for_next_request() -> void:
|
|
var owner_stub := RefCounted.new()
|
|
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
|
add_child(req)
|
|
|
|
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
|
assert_bool(req.is_pending()).is_true()
|
|
|
|
var window: Dictionary = _mock_window(Vector2i(2, 2), 2)
|
|
req.on_response(_mock_response("GJ380c", window))
|
|
assert_bool(req.is_pending()).is_false()
|
|
|
|
# Re-request the SAME (body, center, n) — must be a cache hit, no pending.
|
|
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
|
assert_bool(req.is_pending()).override_failure_message(
|
|
"a second request for an already-resolved window must hit the cache"
|
|
).is_false()
|
|
|
|
|
|
# =============================================================================
|
|
# T-1142 item 2: fit-and-center on entry (Jeroen's "postage stamp" finding)
|
|
# =============================================================================
|
|
|
|
|
|
## enter() must fit-and-center, NOT reset to the old zoom=1.0/offset=ZERO.
|
|
## With a real viewport size set on the Control, the fitted zoom for an
|
|
## n=32 default window must scale up past 1.0 (matches
|
|
## test_atlas_window_geometry.gd's own fit math, exercised here through the
|
|
## real enter() call path instead of the pure function directly).
|
|
func test_enter_fits_and_centers_instead_of_resetting_to_zoom_one() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.size = Vector2(1920.0, 1080.0)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 32)
|
|
assert_float(v.get_view_zoom()).override_failure_message(
|
|
"an n=32 (512px native) composite in a 1920x1080 viewport must be fitted"
|
|
+ " (zoom > 1.0), not left at the old zoom=1.0 postage-stamp default"
|
|
).is_greater(1.0)
|
|
|
|
|
|
## After enter()'s fit, the offset must not be Vector2.ZERO (the old
|
|
## behavior) — it must be the CENTERING offset the fit produces.
|
|
func test_enter_offset_is_not_the_old_zero_default() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.size = Vector2(1920.0, 1080.0)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 32)
|
|
assert_that(v.get_view_offset()).override_failure_message(
|
|
"a fitted+centered composite in a 1920x1080 viewport should not sit at (0,0)"
|
|
).is_not_equal(Vector2.ZERO)
|
|
|
|
|
|
# =============================================================================
|
|
# T-1142 item 3: header carries the body's proper name (cheap half of T-1141)
|
|
# =============================================================================
|
|
|
|
|
|
func test_header_location_label_includes_body_proper_name() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "proper_name": "Lendel"}, {}, Vector2i(5, 5), 2)
|
|
assert_str(v._location_label()).contains("Lendel")
|
|
|
|
|
|
## No proper_name on the body dict -> falls back to body_id (matches
|
|
## AtlasViewer's own _refresh_screen_header fallback chain exactly).
|
|
func test_header_location_label_falls_back_to_body_id() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ903b"}, {}, Vector2i(5, 5), 2)
|
|
assert_str(v._location_label()).contains("GJ903b")
|
|
|
|
|
|
func test_header_location_label_still_includes_the_coordinates() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c", "proper_name": "Lendel"}, {}, Vector2i(42, -7), 2)
|
|
var label: String = v._location_label()
|
|
assert_str(label).contains("42")
|
|
assert_str(label).contains("-7")
|
|
|
|
|
|
# =============================================================================
|
|
# 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.
|
|
# =============================================================================
|
|
|
|
|
|
## _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
|
|
# pan-delta math itself from item 5's clamp in this test.
|
|
var offset_before: Vector2 = v.get_view_offset()
|
|
|
|
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 pan tick must move _view_offset away from its pre-pan value"
|
|
).is_not_equal(offset_before)
|
|
|
|
|
|
## 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: RegionalScreen -> AtlasWindowViewer (T-1153 —
|
|
## RegionalScreen is now the WHOLE ladder's nav entry, superseding the
|
|
## retired DistrictScreen nav hop; see atlas_app.gd's own doc for why the
|
|
## separate "district" screen retired). 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 the
|
|
## screen 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_regional_screen_chain() -> void:
|
|
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
|
add_child(screen)
|
|
screen.enter({"body": {"body_id": "GJ380c"}, "system": {}})
|
|
var offset_before: Vector2 = screen._viewer.get_view_offset()
|
|
|
|
screen._viewer._apply_pan_delta(Vector2(1.0, 0.0), 0.1)
|
|
|
|
assert_that(screen._viewer.get_view_offset()).override_failure_message(
|
|
"a pan tick driven through RegionalScreen'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 (now WASD) pan handler
|
|
# =============================================================================
|
|
|
|
|
|
## 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 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
|
|
# 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)
|
|
|
|
# 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 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
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# T-1142 item 6: east-west wrap — canonicalization on entry + cache reuse
|
|
# =============================================================================
|
|
|
|
|
|
## enter() canonicalizes an out-of-range center BEFORE it becomes
|
|
## _held_center — a column past the body's circumference wraps into range.
|
|
func test_enter_canonicalizes_an_out_of_range_center() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
var radius_km := 6371.0
|
|
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
|
var cols: int = int(extent["cols"])
|
|
v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(cols + 50, 0), 32)
|
|
|
|
var window: Dictionary = _mock_window(Vector2i(50, 0), 32)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
assert_that(v.get_district_window()).override_failure_message(
|
|
"the response must be adopted under the CANONICALIZED center (50, 0),"
|
|
+ " matching what the server would echo back for the wrapped request"
|
|
).is_equal(window)
|
|
|
|
|
|
## A center ONE column past the seam (item 6b): the SAME cache key as its
|
|
## twin at column 0 — a full-circumnavigation pan back to the seam must hit
|
|
## cache, not re-derive, because both requests canonicalize to the same
|
|
## (body, center, n) key.
|
|
func test_center_one_column_past_the_seam_shares_a_cache_key_with_its_twin() -> void:
|
|
var radius_km := 6371.0
|
|
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
|
var cols: int = int(extent["cols"])
|
|
|
|
var v1: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v1)
|
|
v1.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(cols, 50), 32)
|
|
|
|
var v2: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v2)
|
|
v2.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(0, 50), 32)
|
|
|
|
# Both must adopt the SAME server response (keyed on the same
|
|
# canonicalized center) — proves the cache key (and the outbound
|
|
# request) canonicalize identically for the seam and its twin.
|
|
var window: Dictionary = _mock_window(Vector2i(0, 50), 32)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
assert_that(v1.get_district_window()).is_equal(window)
|
|
assert_that(v2.get_district_window()).is_equal(window)
|
|
|
|
|
|
# =============================================================================
|
|
# _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.
|
|
# 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 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)
|
|
|
|
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()
|
|
|
|
v.size = Vector2(1600.0, 900.0)
|
|
v.notification(Control.NOTIFICATION_RESIZED)
|
|
|
|
assert_float(v.get_view_zoom()).override_failure_message(
|
|
"resize while user-adjusted must NOT re-fit — zoom belongs to the user"
|
|
).is_equal_approx(user_zoom, 0.0001)
|
|
assert_vector(v.get_view_offset()).override_failure_message(
|
|
"resize while user-adjusted must NOT re-center — offset belongs to the user"
|
|
).is_equal_approx(user_offset, Vector2(0.001, 0.001))
|
|
|
|
|
|
func test_resize_without_user_adjustment_refits() -> 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)
|
|
var fitted_zoom: float = v.get_view_zoom()
|
|
|
|
v.size = Vector2(640.0, 360.0)
|
|
v.notification(Control.NOTIFICATION_RESIZED)
|
|
|
|
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()
|