fix(ui): PR #203 review round — total retirement + bin decode + wired reset
Tyre finding: the orphaned AtlasViewer cluster is now actually deleted (atlas_viewer, atlas_marker_overlay, atlas_descend_geometry, atlas_legend_panel — a sixth orphan found beyond the review list — atlas_generation_proxy, atlas_generation_state; ~2,497 lines), with reachability re-verified across preload/class_name/res:// strings, every .tscn, and the standalone companion app. Test suites triaged, not blanket-deleted: 5 pure AtlasOverlayColors tests relocated into test_atlas_window_colors, the live type-identity regression guard relocated into test_step_canvas_viewer, dead coverage deleted. A real harness gap surfaced during diligence and RULED, not patched: visual_scenarios/visual_capture golden shots call retired continuous-zoom API — no shim (would resurrect what D-255 kills); inventory recorded on re-scoped T-1157 (gate-invisible, manual targets only). Hoshe finding 1: decode_png_field now detects the [Error, PackedByteArray] bin-shape from messagepack.gd explicitly — a genuine msgpack bin payload decodes correctly instead of silently collapsing to [0,0]; test built from a real round-tripped bin decode. Hoshe finding 2: the hard zoom-out reset is wired — ascend at rung 0 with a drifted view triggers _reset_to_global (the restored HARD condition), behavioral tests through the real input path. Notes folded: refloat + edge-scroll test coverage, legend smoke suite, Vector2i narrowing-safety comment with computed headroom. gdlint clean on touched files; full client suite 3364/3364. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,181 +0,0 @@
|
||||
## T-949 tests: atlas_viewer.gd's _load_markers Sol/non-Sol split and the
|
||||
## CityNamesResponse handler.
|
||||
##
|
||||
## Covers the D-236 Sol guard (legacy synchronous markers.json read, kept
|
||||
## byte-for-byte) and the async non-Sol path (a CityNamesRequest is queued
|
||||
## instead of a direct file read; _markers only populates once the response
|
||||
## arrives). Response shape pinned to dudley-atlas-server's contract
|
||||
## (2026-07-14): {body_id, status, cities: [{city_id, name, is_capital}]},
|
||||
## with a SolExcluded status as the server-side Sol backstop.
|
||||
class_name TestAtlasCityNames
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const SOL_BODY_ID := "GJ0d"
|
||||
const SOL_SYSTEM_ID := "GJ-0"
|
||||
const NON_SOL_BODY_ID := "GJ903b"
|
||||
const NON_SOL_SYSTEM_ID := "GJ-903"
|
||||
|
||||
|
||||
## A real Sol heightmap.png reference, resolved with the SAME formula
|
||||
## _load_sol_markers_legacy() uses for a relative terrain_reference — computed
|
||||
## once here (as an absolute path) so the test doesn't depend on hardcoding
|
||||
## this checkout's location, and doesn't re-derive/guess the resolution logic
|
||||
## separately from production.
|
||||
func _sol_heightmap_ref() -> String:
|
||||
var project_root: String = (
|
||||
ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
|
||||
)
|
||||
return project_root + "/wiki/star-systems/GJ-0/bodies/GJ0d/heightmap.png"
|
||||
|
||||
|
||||
## add_child fires _ready() synchronously (matches test_implant_app_lifecycle.gd's
|
||||
## established pattern) — builds _city_panel/_overlay_node/etc. so show_body()
|
||||
## doesn't null-deref.
|
||||
func _make_viewer() -> AtlasViewer:
|
||||
var v := AtlasViewer.new()
|
||||
add_child(v)
|
||||
return v
|
||||
|
||||
|
||||
func test_sol_body_uses_legacy_synchronous_markers_read() -> void:
|
||||
var v := _make_viewer()
|
||||
var body := {"body_id": SOL_BODY_ID, "terrain_reference": _sol_heightmap_ref()}
|
||||
var system := {"system_id": SOL_SYSTEM_ID}
|
||||
v.show_body(body, system)
|
||||
|
||||
# The legacy path is synchronous — cities/rivers/etc. are populated
|
||||
# immediately, no bridge round-trip needed.
|
||||
var markers: Dictionary = v.get_markers()
|
||||
assert_bool(markers.has("cities")).override_failure_message(
|
||||
"Sol body must keep the legacy full-geometry markers.json read (D-236)"
|
||||
).is_true()
|
||||
assert_int((markers.get("cities", []) as Array).size()).override_failure_message(
|
||||
"Sol's real markers.json must yield at least one city"
|
||||
).is_greater(0)
|
||||
assert_bool(v.has_pending_city_names_request()).override_failure_message(
|
||||
"Sol bodies must never queue a CityNamesRequest (D-236/T-1073 exception)"
|
||||
).is_false()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
func test_non_sol_body_does_not_synchronously_populate_markers() -> void:
|
||||
var v := _make_viewer()
|
||||
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
|
||||
|
||||
# T-949: no more direct file read — markers stay empty until the async
|
||||
# CityNamesResponse arrives (never, in test mode — SimBridge has no server).
|
||||
assert_that(v.get_markers()).override_failure_message(
|
||||
"non-Sol bodies must not synchronously populate markers from a file read"
|
||||
).is_equal({})
|
||||
assert_bool(v.has_pending_city_names_request()).override_failure_message(
|
||||
"non-Sol bodies must queue a CityNamesRequest for their own body"
|
||||
).is_true()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
func test_city_names_received_ready_stores_under_dedicated_key() -> void:
|
||||
var v := _make_viewer()
|
||||
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
|
||||
|
||||
v._on_city_names_received(
|
||||
{
|
||||
"body_id": NON_SOL_BODY_ID,
|
||||
"status": "Ready",
|
||||
"cities": [{"city_id": 1, "name": "Ridgeback", "is_capital": false}],
|
||||
}
|
||||
)
|
||||
|
||||
# Stored under "city_names", NOT the legacy top-level "cities" key —
|
||||
# CityNameEntry has no position, so merging it into "cities" would make
|
||||
# _draw_cities()/_find_city_at() plot every entry at Vector2.ZERO.
|
||||
var markers: Dictionary = v.get_markers()
|
||||
assert_that(markers.get("cities", [])).override_failure_message(
|
||||
"non-Sol markers must NOT expose position-less entries under the top-level 'cities' key"
|
||||
).is_equal([])
|
||||
assert_int((markers.get("city_names", []) as Array).size()).is_equal(1)
|
||||
assert_str((markers.get("city_names", [])[0] as Dictionary).get("name")).is_equal("Ridgeback")
|
||||
assert_bool(v.has_pending_city_names_request()).is_false()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
func test_city_names_received_sol_excluded_falls_back_to_legacy_read() -> void:
|
||||
var v := _make_viewer()
|
||||
# Body claims to be non-Sol at request time, but the server's own D-236
|
||||
# backstop says otherwise — the defensive fallback must still work even
|
||||
# though this shouldn't happen given atlas_viewer.gd's own SOL_SYSTEM_ID
|
||||
# guard (belt-and-suspenders per dudley-atlas-server's contract note).
|
||||
v.show_body(
|
||||
{"body_id": SOL_BODY_ID, "terrain_reference": _sol_heightmap_ref()},
|
||||
{"system_id": NON_SOL_SYSTEM_ID}
|
||||
)
|
||||
assert_bool(v.has_pending_city_names_request()).is_true()
|
||||
|
||||
v._on_city_names_received({"body_id": SOL_BODY_ID, "status": "SolExcluded", "cities": []})
|
||||
|
||||
var markers: Dictionary = v.get_markers()
|
||||
assert_bool(markers.has("cities")).override_failure_message(
|
||||
"SolExcluded must fall back to the legacy full-geometry markers.json read"
|
||||
).is_true()
|
||||
assert_int((markers.get("cities", []) as Array).size()).is_greater(0)
|
||||
assert_bool(v.has_pending_city_names_request()).is_false()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
func test_city_names_received_ignores_stale_body_response() -> void:
|
||||
var v := _make_viewer()
|
||||
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
|
||||
|
||||
# A response for a DIFFERENT body (the viewer navigated away while the
|
||||
# request was in flight) must not clobber state.
|
||||
v._on_city_names_received(
|
||||
{"body_id": "some_other_body", "status": "Ready", "cities": [{"name": "Nope"}]}
|
||||
)
|
||||
|
||||
assert_that(v.get_markers()).override_failure_message(
|
||||
"a stale-body CityNamesResponse must be ignored"
|
||||
).is_equal({})
|
||||
assert_bool(v.has_pending_city_names_request()).override_failure_message(
|
||||
"a stale-body response must not clear the real pending request"
|
||||
).is_true()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
func test_city_names_received_error_status_leaves_markers_empty() -> void:
|
||||
var v := _make_viewer()
|
||||
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
|
||||
|
||||
# Normalized string form — production handlers only ever see the output of
|
||||
# Protocol.city_names_response_from_raw, which reduces {"Error": msg} to
|
||||
# "Error" (review H5: the raw wire shape only passed by str() coincidence).
|
||||
v._on_city_names_received(
|
||||
{"body_id": NON_SOL_BODY_ID, "status": "Error", "cities": []}
|
||||
)
|
||||
|
||||
assert_that(v.get_markers()).override_failure_message(
|
||||
"an Error status must leave markers empty (#960/D-191's empty-markers case)"
|
||||
).is_equal({})
|
||||
assert_bool(v.has_pending_city_names_request()).is_false()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
func test_connection_state_change_does_not_crash_or_clear_pending_marker() -> void:
|
||||
var v := _make_viewer()
|
||||
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
|
||||
assert_bool(v.has_pending_city_names_request()).is_true()
|
||||
|
||||
# SimBridge is in test_mode (no live connection) so request_city_names()
|
||||
# no-ops either way — this proves the handler doesn't crash, and that only
|
||||
# a real CityNamesResponse (not the mere state transition) clears the
|
||||
# pending marker.
|
||||
v._on_connection_state_changed(
|
||||
SimBridge.ConnectionState.CONNECTING, SimBridge.ConnectionState.CONNECTED
|
||||
)
|
||||
assert_bool(v.has_pending_city_names_request()).is_true()
|
||||
|
||||
v.queue_free()
|
||||
@@ -1,523 +0,0 @@
|
||||
## T-1138 (D-226 T-1124 amendment §5 entry revision): tests for the planetary
|
||||
## AtlasViewer's click-through descent — the fixed view (no drag-pan/wheel-
|
||||
## zoom), the pixel-to-DistrictPos inverse mapping (atlas_descend_geometry.gd),
|
||||
## and the city-click-wins disambiguation rule.
|
||||
class_name TestAtlasDescendEntry
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# atlas_descend_geometry.gd — pure geometry (no scene tree needed)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The default n=32 window's real footprint is 32 * 2.048 km = 65.536 km, so
|
||||
## the label reads "~66 x 66 km" (rounded) — pins the number the amendment's
|
||||
## "honest labeling" resolution depends on.
|
||||
func test_reticle_label_extent_matches_district_window_default_n() -> void:
|
||||
var label: Dictionary = AtlasDescendGeometry.reticle_label(Vector2(100.0, 100.0))
|
||||
var expected_km: float = 32.0 * 2048.0 / 1000.0
|
||||
assert_str(label["text"]).is_equal("~%.0f × %.0f km" % [expected_km, expected_km])
|
||||
|
||||
|
||||
func test_reticle_label_position_offsets_from_center() -> void:
|
||||
var center := Vector2(50.0, 60.0)
|
||||
var label: Dictionary = AtlasDescendGeometry.reticle_label(center)
|
||||
var pos: Vector2 = label["position"]
|
||||
assert_float(pos.x).is_greater(center.x) # offset to the right, per §5's resolution
|
||||
|
||||
|
||||
## Eight segments (four L-shaped bracket corners, two arms each) — every
|
||||
## segment's "from" endpoint is exactly one of the four corner anchors, and no
|
||||
## segment has zero length (a degenerate reticle would be invisible).
|
||||
func test_reticle_segments_has_eight_nonzero_segments() -> void:
|
||||
var segments: Array = AtlasDescendGeometry.reticle_segments(Vector2(200.0, 150.0))
|
||||
assert_int(segments.size()).is_equal(8)
|
||||
for seg: Array in segments:
|
||||
assert_that(seg[0]).override_failure_message(
|
||||
"a reticle segment must not be zero-length"
|
||||
).is_not_equal(seg[1])
|
||||
|
||||
|
||||
## The reticle's overall bounding box is centered on `center` and sized by
|
||||
## DESCEND_RETICLE_SIZE — a regression guard against an off-center or
|
||||
## mis-scaled bracket.
|
||||
func test_reticle_segments_centered_on_input_point() -> void:
|
||||
var center := Vector2(300.0, 300.0)
|
||||
var segments: Array = AtlasDescendGeometry.reticle_segments(center)
|
||||
var min_pt := Vector2(INF, INF)
|
||||
var max_pt := Vector2(-INF, -INF)
|
||||
for seg: Array in segments:
|
||||
for p: Vector2 in seg:
|
||||
min_pt.x = minf(min_pt.x, p.x)
|
||||
min_pt.y = minf(min_pt.y, p.y)
|
||||
max_pt.x = maxf(max_pt.x, p.x)
|
||||
max_pt.y = maxf(max_pt.y, p.y)
|
||||
var bbox_center: Vector2 = (min_pt + max_pt) * 0.5
|
||||
assert_float(bbox_center.x).is_equal_approx(center.x, 0.01)
|
||||
assert_float(bbox_center.y).is_equal_approx(center.y, 0.01)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# district_pos_at — pixel-to-DistrictPos inverse mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## No radius (tiny test body fallback, matches the server's own derive_district
|
||||
## fallback): the district grid IS the heightmap grid 1:1, so a canvas point
|
||||
## maps to the nearest integer district coordinate directly.
|
||||
func test_district_pos_at_no_radius_is_1to1_pixel_mapping() -> void:
|
||||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(12.4, 7.6), 100.0, 100.0, 0.0
|
||||
)
|
||||
assert_that(pos).is_equal(Vector2i(12, 8))
|
||||
|
||||
|
||||
func test_district_pos_at_zero_texture_size_is_safe() -> void:
|
||||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(Vector2(10.0, 10.0), 0.0, 0.0, 6371.0)
|
||||
assert_that(pos).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## The equatorial center of the texture (px = tex_w/2) is district column
|
||||
## district_cols/2 on a body with a radius (equator wraps at district (0,0)
|
||||
## per the server's own doc: "district (0,0) sits at lon 0 / the equator").
|
||||
##
|
||||
## The TRUE equator pixel row is `(tex_h - 1) * 0.5`, NOT `tex_h * 0.5` (PR
|
||||
## #187 review, Hoshe): the server's forward map (district_profile.rs:1436)
|
||||
## divides by `ta.h.saturating_sub(1)`, so row 0 and row (h-1) are the real
|
||||
## clamped pole endpoints and the midpoint between them is `(h-1)/2`. This
|
||||
## test originally sampled `tex_h * 0.5`, which is NOT that midpoint for any
|
||||
## finite tex_h — it happened to still round to district row 0 under BOTH the
|
||||
## pre-fix buggy formula and the fix, at this specific radius/tex_h pair,
|
||||
## which is exactly why this equator-only sample masked the ÷tex_h bug
|
||||
## instead of catching it (see the off-equator round-trip tests below for the
|
||||
## real regression coverage). Corrected here to the genuine equator pixel so
|
||||
## this test asserts something true about the fixed formula, not a
|
||||
## coincidence of the old one.
|
||||
func test_district_pos_at_center_of_texture_is_near_equator_row_zero() -> void:
|
||||
var radius_km := 6371.0
|
||||
var tex_w := 1024.0
|
||||
var tex_h := 512.0
|
||||
var true_equator_py: float = (tex_h - 1.0) * 0.5
|
||||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(0.0, true_equator_py), tex_w, tex_h, radius_km
|
||||
)
|
||||
assert_int(pos.y).override_failure_message(
|
||||
"the true equator pixel ((tex_h-1)/2) must map to row 0"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
## Panning across the texture's full width sweeps through the FULL district
|
||||
## column range (not clamped to a tiny sub-range) — a coarse sanity check that
|
||||
## district_cols is actually being derived from the body's circumference, not
|
||||
## left at some degenerate default.
|
||||
func test_district_pos_at_sweeps_full_column_range_across_texture_width() -> void:
|
||||
var radius_km := 6371.0
|
||||
var tex_w := 1024.0
|
||||
var tex_h := 512.0
|
||||
var left: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||||
)
|
||||
var right: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(tex_w - 1.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||||
)
|
||||
# An Earth-radius body has thousands of equatorial districts (circumference
|
||||
# ~40,075 km / 2.048 km per district ≈ 19,568) — near-full-width should
|
||||
# sweep a large fraction of that, not a handful of columns.
|
||||
assert_int(absi(right.x - left.x)).is_greater(1000)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Off-equator round-trip (PR #187 review, Hoshe — BLOCKING, live-repro'd bug):
|
||||
# district_pos_at's row inverse divided by tex_h instead of (tex_h - 1),
|
||||
# matching the WRONG symmetry with the column inverse (which correctly
|
||||
# divides by the plain tex_w, since longitude wraps and has no edge case).
|
||||
# The server's forward map (district_profile.rs:1436) uses
|
||||
# ta.h.saturating_sub(1) for the SAME reason the ground-truth inverse
|
||||
# (aliveness_probe.rs:511, `row / (ta_h - 1) - 0.5`) does: latitude CLAMPS at
|
||||
# the poles, so row 0 / row (h-1) are real endpoints the division must land
|
||||
# on exactly. This class of bug — a client-side twin of a server coordinate
|
||||
# mapping silently drifting out of sync — is exactly what Tyre's C2 review
|
||||
# criterion requires a round-trip drift guard for; that is what every test in
|
||||
# this section is. The suite previously missed it because the only
|
||||
# real-radius latitude sample was py = tex_h*0.5 (test above), the ONE point
|
||||
## where the buggy (÷tex_h) and correct (÷(tex_h-1)) formulas round to the
|
||||
# same integer district row — never exercising the asymmetry the fix targets.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Forward-maps a district row to its heightmap pixel row using the server's
|
||||
## OWN formula, transcribed here (district_profile.rs:1436):
|
||||
## let lat_frac = (dy * DISTRICT_M / meridian_m).clamp(-0.5, 0.5);
|
||||
## let py = (0.5 + lat_frac) * ta.h.saturating_sub(1);
|
||||
## `dy` is a DistrictPos.y component (not a pixel) — the caller supplies the
|
||||
## same `district_rows_half`-scaled value district_pos_at()'s inverse would
|
||||
## need to recover, so this function and district_pos_at() are meant to be
|
||||
## exact inverses of one another for any row within the clamped range.
|
||||
static func _server_forward_map_row(district_row: int, tex_h: float, radius_km: float) -> float:
|
||||
var meridian_m: float = PI * radius_km * 1000.0
|
||||
var district_m: float = AtlasDescendGeometry.DISTRICT_M
|
||||
var lat_frac: float = clampf((float(district_row) * district_m) / meridian_m, -0.5, 0.5)
|
||||
return (0.5 + lat_frac) * (tex_h - 1.0)
|
||||
|
||||
|
||||
## The actual regression: forward-map three off-equator district rows (one
|
||||
## per hemisphere, plus a near-pole extreme) to pixel rows via the server's
|
||||
## transcribed formula, then assert district_pos_at() recovers each EXACT
|
||||
## row from that pixel. Fails outright under the pre-fix ÷tex_h formula for
|
||||
## every row here except (coincidentally) very near the equator.
|
||||
func test_district_pos_at_round_trips_off_equator_rows_against_server_forward_map() -> void:
|
||||
var radius_km := 6238.4 # GJ380c (server/data/systems.db) — a real body, not a round number
|
||||
var tex_w := 1024.0
|
||||
var tex_h := 512.0
|
||||
var col := 400 # arbitrary — this test is about the row axis only
|
||||
|
||||
# One row per hemisphere (moderate latitude) + one extreme near-pole row.
|
||||
# meridian_m / DISTRICT_M ≈ 9,569 for this radius, so district_rows_half
|
||||
# ≈ 4,785 — these rows sit well inside that range without saturating the
|
||||
# clamp (except the "near pole" case, deliberately close to the edge).
|
||||
var test_rows: Array = [-1200, 900, -4700]
|
||||
|
||||
for district_row: int in test_rows:
|
||||
var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km)
|
||||
var recovered: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(float(col), forward_py), tex_w, tex_h, radius_km
|
||||
)
|
||||
assert_int(recovered.y).override_failure_message(
|
||||
(
|
||||
"row round-trip failed: district_row=%d -> forward pixel py=%.4f -> "
|
||||
+ "district_pos_at recovered row=%d (drift=%d)"
|
||||
) % [district_row, forward_py, recovered.y, recovered.y - district_row]
|
||||
).is_equal(district_row)
|
||||
|
||||
|
||||
## Same round-trip, restated as an explicit non-equator drift guard: the
|
||||
## pre-fix bug produced a WRONG but still-integer row (Hoshe's live repro:
|
||||
## forward row 50, buggy inverse returned 45 — a 5-district, ~10.2 km drift)
|
||||
## — a coarse "is it roughly right" tolerance would have passed that. This
|
||||
## asserts EXACT equality specifically at a moderate off-equator row, not an
|
||||
## approximate one, so a reintroduced ÷tex_h regression fails loudly again.
|
||||
func test_district_pos_at_off_equator_row_is_not_off_by_a_few_districts() -> void:
|
||||
var radius_km := 6238.4
|
||||
var tex_w := 1024.0
|
||||
var tex_h := 512.0
|
||||
var district_row := 2500
|
||||
var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km)
|
||||
var recovered: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(512.0, forward_py), tex_w, tex_h, radius_km
|
||||
)
|
||||
assert_int(recovered.y).is_equal(district_row)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1142 item 1: is_on_texture() — the letterbox bounds gate
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_is_on_texture_true_for_a_point_inside_the_texture() -> void:
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, 250.0), 1024.0, 512.0)).is_true()
|
||||
|
||||
|
||||
func test_is_on_texture_true_at_the_top_left_origin() -> void:
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2.ZERO, 1024.0, 512.0)).is_true()
|
||||
|
||||
|
||||
## Half-open range [0, tex_w) x [0, tex_h) — the last valid pixel is tex_w-1 /
|
||||
## tex_h-1, NOT tex_w/tex_h themselves (canvas_pt.x == tex_w is one pixel
|
||||
## PAST the texture, the classic off-by-one a naive <= bound would miss).
|
||||
func test_is_on_texture_false_exactly_at_the_texture_width_bound() -> void:
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(1024.0, 250.0), 1024.0, 512.0)).is_false()
|
||||
|
||||
|
||||
func test_is_on_texture_false_exactly_at_the_texture_height_bound() -> void:
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, 512.0), 1024.0, 512.0)).is_false()
|
||||
|
||||
|
||||
## Jeroen's exact repro shape: a letterbox click lands far PAST the texture
|
||||
## width in canvas space (a wide viewport around a 2:1-fitted heightmap).
|
||||
func test_is_on_texture_false_for_a_letterbox_point_past_texture_width() -> void:
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(1400.0, 250.0), 1024.0, 512.0)).is_false()
|
||||
|
||||
|
||||
func test_is_on_texture_false_for_negative_coordinates() -> void:
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(-10.0, 250.0), 1024.0, 512.0)).is_false()
|
||||
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, -10.0), 1024.0, 512.0)).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1142 item 6a: canonicalize_district_center() — column wraps, row clamps
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Jeroen's own repro column (12276) on a body whose circumference works out
|
||||
## to ~11236 districts (body_radius_km chosen so district_extent().cols ==
|
||||
## 11236 as closely as the rounding allows) wraps down into range — the exact
|
||||
## scenario the letterbox click hit before the bounds gate (item 1) made it
|
||||
## unreachable via the UI, but canonicalization is still the correct backstop
|
||||
## for any out-of-range center this or a future path constructs.
|
||||
func test_canonicalize_wraps_a_column_past_the_circumference() -> void:
|
||||
var radius_km := 6371.0 # -> district_extent().cols ~= 19,568 (Earth-like)
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var out_of_range := Vector2i(cols + 100, 0)
|
||||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
out_of_range, radius_km
|
||||
)
|
||||
assert_int(canonical.x).is_equal(100)
|
||||
assert_int(canonical.y).is_equal(0)
|
||||
|
||||
|
||||
## A column ONE past the wrap seam (cols) canonicalizes to column 0 — its
|
||||
## "twin" on the other side of the antimeridian. This is the exact identity
|
||||
## AtlasWindowCache.make_key() depends on for item 6b (a full-circumnavigation
|
||||
## pan hits cache, not a fresh derive) — tested directly on the cache in
|
||||
## test_atlas_window_viewer.gd; this pins the canonicalization half alone.
|
||||
func test_canonicalize_one_column_past_the_seam_matches_its_twin() -> void:
|
||||
var radius_km := 6371.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var past_seam: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
Vector2i(cols, 50), radius_km
|
||||
)
|
||||
var at_seam: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
Vector2i(0, 50), radius_km
|
||||
)
|
||||
assert_that(past_seam).override_failure_message(
|
||||
"column `cols` and column 0 are the same antimeridian-adjacent point"
|
||||
).is_equal(at_seam)
|
||||
|
||||
|
||||
## Negative columns wrap too (Euclidean, not truncating) — a pan that crosses
|
||||
## the seam going WEST must land in [0, cols), not go negative.
|
||||
func test_canonicalize_wraps_a_negative_column() -> void:
|
||||
var radius_km := 6371.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
Vector2i(-5, 0), radius_km
|
||||
)
|
||||
assert_int(canonical.x).is_equal(cols - 5)
|
||||
|
||||
|
||||
## Rows CLAMP, never wrap — a row past +rows_half pins to +rows_half exactly
|
||||
## (the pole), matching the server's normalize_window_center() clamp
|
||||
## disposition (latitude terminates, it does not wrap around).
|
||||
func test_canonicalize_clamps_a_row_past_the_pole() -> void:
|
||||
var radius_km := 6371.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
Vector2i(0, rows_half + 500), radius_km
|
||||
)
|
||||
assert_int(canonical.y).is_equal(rows_half)
|
||||
var canonical_south: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
Vector2i(0, -rows_half - 500), radius_km
|
||||
)
|
||||
assert_int(canonical_south.y).is_equal(-rows_half)
|
||||
|
||||
|
||||
## An already-in-range center is a no-op (identity) — canonicalization must
|
||||
## never perturb a legitimate, already-valid request.
|
||||
func test_canonicalize_is_identity_for_an_in_range_center() -> void:
|
||||
var radius_km := 6371.0
|
||||
var in_range := Vector2i(500, 100)
|
||||
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
in_range, radius_km
|
||||
)
|
||||
assert_that(canonical).is_equal(in_range)
|
||||
|
||||
|
||||
## No-radius bodies (tiny test bodies) are identity — matching
|
||||
## normalize_window_center()'s own no-radius disposition (no periodicity
|
||||
## concept at the DistrictPos level for a body with no radius).
|
||||
func test_canonicalize_no_radius_is_identity() -> void:
|
||||
var anything := Vector2i(99999, -99999)
|
||||
assert_that(AtlasDescendGeometry.canonicalize_district_center(anything, 0.0)).is_equal(anything)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AtlasViewer — fixed view (no drag-pan/wheel-zoom) + click-through descent
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## T-1120 capture API (set_view/get_view_offset/get_view_zoom) must survive
|
||||
## the removal of user pan/zoom — this is the ticket's own explicit note, and
|
||||
## the visual-golden harness depends on it.
|
||||
func test_set_view_still_works_after_pan_zoom_removal() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v.set_view(3.0, Vector2(50.0, -20.0))
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001)
|
||||
assert_that(v.get_view_offset()).is_equal(Vector2(50.0, -20.0))
|
||||
|
||||
|
||||
## Clicking (with no heightmap loaded — the guard AtlasViewer's _gui_input
|
||||
## checks first) must not emit a descend request — there is nothing to
|
||||
## descend into yet.
|
||||
func test_no_descend_signal_without_a_loaded_heightmap() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
var received: Array = []
|
||||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||||
# _heightmap_texture stays null (no show_body() call) — _gui_input's
|
||||
# early-return guard should prevent any click handling at all.
|
||||
var mb := InputEventMouseButton.new()
|
||||
mb.button_index = MOUSE_BUTTON_LEFT
|
||||
mb.pressed = true
|
||||
mb.position = Vector2(100.0, 100.0)
|
||||
v._gui_input(mb)
|
||||
assert_int(received.size()).is_equal(0)
|
||||
|
||||
|
||||
## T-1182 (D-255 stepped Atlas ladder): RegionalScreen no longer wraps
|
||||
## AtlasViewer or forwards district_descend_requested — the "regional" nav
|
||||
## entry opens the stepped six-rung ladder (StepCanvasViewer) directly at the
|
||||
## Global opener (rung 0) — see regional_screen.gd's own doc. This
|
||||
## regression-guards the wiring: entering "regional" reaches
|
||||
## StepCanvasViewer, not AtlasViewer.
|
||||
func test_regional_screen_wraps_step_canvas_viewer_not_atlas_viewer() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
assert_object(screen._viewer).override_failure_message(
|
||||
"RegionalScreen must wrap StepCanvasViewer (the stepped ladder) since T-1182,"
|
||||
+ " not the retired AtlasViewer heightmap-texture display"
|
||||
).is_instanceof(StepCanvasViewer)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PR #187 review (Araminta — broken promise): the descend reticle must be
|
||||
# hidden while hovering a city marker — _hovered_city already flares white as
|
||||
# the "click here for city data" signal, and _try_click_city wins the click
|
||||
# over descent, so drawing the reticle at the same time promised a descent
|
||||
# the click would never perform. State-level tests (per the review's own
|
||||
# "state-level is fine" allowance): assert _should_draw_descend_reticle()'s
|
||||
# condition directly against _hover_active/_hovered_city/_heightmap_texture,
|
||||
# rather than a pixel-diff on the actual draw call.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
static func _one_pixel_texture() -> ImageTexture:
|
||||
var img := Image.create(1, 1, false, Image.FORMAT_RGB8)
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## Hovering EMPTY map (no city under the cursor) -> reticle SHOWS.
|
||||
func test_descend_reticle_shows_when_hovering_empty_map() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v._heightmap_texture = _one_pixel_texture()
|
||||
v._hover_active = true
|
||||
v._hovered_city = {}
|
||||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||||
"reticle must show when hovering the open map (no city under cursor)"
|
||||
).is_true()
|
||||
|
||||
|
||||
## Hovering a CITY marker -> reticle HIDES, even though _hover_active is true
|
||||
## (this is the exact bug: pre-fix, _hovered_city was never consulted here).
|
||||
func test_descend_reticle_hides_when_hovering_a_city() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v._heightmap_texture = _one_pixel_texture()
|
||||
v._hover_active = true
|
||||
v._hovered_city = {"name": "Ridgeback", "pos": [10, 20]}
|
||||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||||
"reticle must hide while hovering a city — the click would open city"
|
||||
+ " data (_try_click_city wins), not descend"
|
||||
).is_false()
|
||||
|
||||
|
||||
## Not hovering at all (_hover_active false) -> reticle HIDES regardless of
|
||||
## _hovered_city — the pre-existing base condition, guarded here so the city
|
||||
## fix can't accidentally invert it.
|
||||
func test_descend_reticle_hides_when_not_hovering() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v._heightmap_texture = _one_pixel_texture()
|
||||
v._hover_active = false
|
||||
v._hovered_city = {}
|
||||
assert_bool(v._should_draw_descend_reticle()).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1142 item 1: the letterbox bounds gate, wired into AtlasViewer's own
|
||||
# reticle guard and click fall-through (fields default to _tex_w=1024/
|
||||
# _tex_h=512, _view_zoom=1.0, _view_offset=ZERO — screen_to_canvas() is
|
||||
# therefore the identity transform in these tests, so a screen point maps
|
||||
# 1:1 to the same canvas point, matching this file's own established
|
||||
## _one_pixel_texture() convention above (the LOADED texture's real
|
||||
# dimensions don't matter here — only _tex_w/_tex_h do).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## An on-map screen point (well inside [0, 1024) x [0, 512)) shows the
|
||||
## reticle — the ordinary, expected case.
|
||||
func test_descend_reticle_shows_for_an_on_texture_point() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v._heightmap_texture = _one_pixel_texture()
|
||||
v._hover_active = true
|
||||
v._hovered_city = {}
|
||||
v._hover_screen_pos = Vector2(500.0, 250.0)
|
||||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||||
"reticle must show for a point on the heightmap texture"
|
||||
).is_true()
|
||||
|
||||
|
||||
## Jeroen's exact repro shape: a letterbox point (canvas x >= tex_w, i.e. past
|
||||
## the right edge of a fitted 2:1 heightmap in a wider viewport) HIDES the
|
||||
## reticle — the affordance must never promise a descent the click can't
|
||||
## honestly perform.
|
||||
func test_descend_reticle_hides_for_a_letterbox_point_past_texture_width() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v._heightmap_texture = _one_pixel_texture()
|
||||
v._hover_active = true
|
||||
v._hovered_city = {}
|
||||
v._hover_screen_pos = Vector2(1400.0, 250.0) # past _tex_w=1024
|
||||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||||
"reticle must hide for a letterbox point past the texture's right edge"
|
||||
).is_false()
|
||||
|
||||
|
||||
## Same shape, Y axis — a letterbox point above/below a fitted heightmap
|
||||
## (narrow-viewport case) must also hide the reticle.
|
||||
func test_descend_reticle_hides_for_a_letterbox_point_past_texture_height() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v._heightmap_texture = _one_pixel_texture()
|
||||
v._hover_active = true
|
||||
v._hovered_city = {}
|
||||
v._hover_screen_pos = Vector2(500.0, 900.0) # past _tex_h=512
|
||||
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
|
||||
"reticle must hide for a letterbox point past the texture's bottom edge"
|
||||
).is_false()
|
||||
|
||||
|
||||
## The click fall-through mirrors the reticle exactly (item 1's "one truth"
|
||||
## requirement) — an on-texture click DOES emit district_descend_requested.
|
||||
func test_descend_at_emits_for_an_on_texture_click() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
var received: Array = []
|
||||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||||
v._descend_at(Vector2(500.0, 250.0))
|
||||
assert_int(received.size()).override_failure_message(
|
||||
"an on-texture click must emit district_descend_requested"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
## A letterbox click is INERT — no signal at all, matching the reticle never
|
||||
## having shown a promise there. This is Jeroen's exact repro: a letterbox
|
||||
## click must never derive a DistrictPos, on-texture or off.
|
||||
func test_descend_at_is_inert_for_a_letterbox_click() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
var received: Array = []
|
||||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||||
v._descend_at(Vector2(1400.0, 250.0))
|
||||
assert_int(received.size()).override_failure_message(
|
||||
"a letterbox click must be inert — no district_descend_requested at all"
|
||||
).is_equal(0)
|
||||
@@ -1,541 +0,0 @@
|
||||
## Tests for the Phase-4 generation overlays in the Atlas viewer (#960, D-225).
|
||||
## Referencing AtlasViewer forces both it and AtlasMarkerOverlay to compile, so
|
||||
## this also guards against parse errors in the overlay rendering code.
|
||||
class_name TestAtlasOverlays
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# atlas_legend_panel.gd has no class_name (matches atlas_overlay_bar.gd, review
|
||||
# #8), so its GENERATION_LEGEND spec table is read off the preloaded Script
|
||||
# resource rather than a global identifier.
|
||||
const LegendPanelScript := preload("res://ui/implant/apps/atlas/atlas_legend_panel.gd")
|
||||
|
||||
# T-1118/T-1119 pure color-ramp/shape-selection helpers (no class_name, same
|
||||
# rationale as LegendPanelScript above) — see atlas_overlay_colors.gd.
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
|
||||
func test_generation_overlays_registered() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(
|
||||
["gen_l1_rivers", "gen_l1_basins", "gen_l1_attractors", "gen_district"]
|
||||
)
|
||||
|
||||
|
||||
## T-1046: the district/morphology grid round-trips through the viewer and the
|
||||
## protocol decode surfaces it; referencing AtlasMarkerOverlay compiles the draw.
|
||||
func test_district_grid_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_district_grid()).is_null()
|
||||
var grid := {
|
||||
"cols": 2,
|
||||
"rows": 1,
|
||||
"morphology": PackedByteArray([8, 14]),
|
||||
"elev_q": PackedByteArray([10, 90]),
|
||||
}
|
||||
v.set_generation_district_grid(grid)
|
||||
assert_that(v.get_generation_district_grid()).is_equal(grid)
|
||||
# The decoded response dict carries the district_grid key (protocol.gd, T-1046).
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "district_grid": grid}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("district_grid")).is_equal(grid)
|
||||
|
||||
|
||||
func test_generation_data_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_layer1()).is_null()
|
||||
var mock := {
|
||||
"body_id": "GJ1c",
|
||||
"river_network": {"river_cells": [], "confluences": [], "mouths": []},
|
||||
"drainage_basins": [],
|
||||
"attractors": [],
|
||||
}
|
||||
v.set_generation_layer1(mock)
|
||||
assert_that(v.get_generation_layer1()).is_equal(mock)
|
||||
|
||||
|
||||
func test_implant_pending_start_stop() -> void:
|
||||
var p: ImplantPending = auto_free(ImplantPending.new())
|
||||
p.start("GENERATING LAYER 1")
|
||||
assert_bool(p.visible).is_true()
|
||||
p.stop()
|
||||
assert_bool(p.visible).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-960: gen_l2_roads / gen_l3_settlements overlays
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_l2_l3_overlays_registered() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(["gen_l2_roads", "gen_l3_settlements"])
|
||||
|
||||
|
||||
## RoadGraphLayer shape pinned to dudley-atlas-server's contract (2026-07-14):
|
||||
## nodes carry NO `degree` (trimmed as server-internal bookkeeping — the
|
||||
## overlay derives it from edge endpoints instead, see _draw_gen_roads).
|
||||
func test_road_graph_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_road_graph()).is_null()
|
||||
var road_graph := {
|
||||
"nodes":
|
||||
[
|
||||
{"city_id": 1, "position": [10, 20], "kind": "Settlement"},
|
||||
{"city_id": null, "position": [12, 22], "kind": "Waypoint"},
|
||||
],
|
||||
"edges":
|
||||
[
|
||||
{
|
||||
"from": 0,
|
||||
"to": 1,
|
||||
"path": [[10, 20], [12, 22]],
|
||||
"maintenance": "Administrative",
|
||||
"is_rail": false,
|
||||
"named_route_id": null,
|
||||
},
|
||||
],
|
||||
}
|
||||
v.set_generation_road_graph(road_graph)
|
||||
assert_that(v.get_generation_road_graph()).is_equal(road_graph)
|
||||
# Round-trips through the protocol decode too (T-1046 district_grid precedent).
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "road_graph": road_graph}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("road_graph")).is_equal(road_graph)
|
||||
|
||||
|
||||
## SettlementLayer shape pinned to dudley-atlas-server's contract
|
||||
## (2026-07-14): wrapped under "settlements" (not "cities"); size_class is
|
||||
## categorical (Major/Standard/Minor), and is_capital is authored, not
|
||||
## population-derived.
|
||||
func test_settlements_round_trip() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_settlements()).is_null()
|
||||
var settlements := {
|
||||
"settlements":
|
||||
[
|
||||
{
|
||||
"city_id": 1,
|
||||
"name": "Ridgeback",
|
||||
"position": [30, 40],
|
||||
"size_class": "Minor",
|
||||
"is_capital": false,
|
||||
"is_port": false,
|
||||
},
|
||||
{
|
||||
"city_id": 2,
|
||||
"name": "Capital City",
|
||||
"position": [35, 45],
|
||||
"size_class": "Major",
|
||||
"is_capital": true,
|
||||
"is_port": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
v.set_generation_settlements(settlements)
|
||||
assert_that(v.get_generation_settlements()).is_equal(settlements)
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "settlements": settlements}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1118: gen_region_grid overlay (region climate grid, mean-temp channel)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_region_grid_overlay_registered() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(["gen_region_grid"])
|
||||
|
||||
|
||||
## RegionGridLayer shape pinned to server/src/atlas/layer_proxy.rs (T-1113):
|
||||
## dense row-major, mean_temp_dc is deci-degC with REGION_TEMP_NONE_DC
|
||||
## (i16::MIN) the airless sentinel.
|
||||
func test_region_grid_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_region_grid()).is_null()
|
||||
var grid := {
|
||||
"cols": 2,
|
||||
"rows": 1,
|
||||
"season": [0, 3],
|
||||
"weather": [0, 2],
|
||||
"mean_temp_dc": [123, AtlasOverlayColors.REGION_TEMP_NONE_DC],
|
||||
"moisture_q": [80, 5],
|
||||
}
|
||||
v.set_generation_region_grid(grid)
|
||||
assert_that(v.get_generation_region_grid()).is_equal(grid)
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "region_grid": grid}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("region_grid")).is_equal(grid)
|
||||
|
||||
|
||||
## AtlasOverlayColors.region_temp_color() pure ramp — the one visual channel
|
||||
## this ticket ships (mean temp only; season/weather/moisture deferred).
|
||||
## Compares components with is_equal_approx() rather than whole-Color
|
||||
## is_equal(): Godot's Color.lerp(a, b, 1.0) is NOT bit-exact to b (confirmed
|
||||
## empirically — the two print identically but == is false at the ULP
|
||||
## level), so an exact Color equality check is the wrong tool at a lerp
|
||||
## boundary regardless of whether the ramp math itself is correct.
|
||||
func test_region_temp_color_ramp() -> void:
|
||||
# Cold end clamps to pure cold color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MIN_DC),
|
||||
AtlasOverlayColors.COLOR_REGION_TEMP_COLD
|
||||
)
|
||||
# Hot end clamps to pure hot color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MAX_DC),
|
||||
AtlasOverlayColors.COLOR_REGION_TEMP_HOT
|
||||
)
|
||||
# Midpoint (0.0 C) lands on the mid color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(0), AtlasOverlayColors.COLOR_REGION_TEMP_MID
|
||||
)
|
||||
# Out-of-band readings clamp rather than extrapolate past the endpoints.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(-9999), AtlasOverlayColors.COLOR_REGION_TEMP_COLD
|
||||
)
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(9999), AtlasOverlayColors.COLOR_REGION_TEMP_HOT
|
||||
)
|
||||
|
||||
|
||||
func _assert_color_approx(actual: Color, expected: Color) -> void:
|
||||
assert_float(actual.r).is_equal_approx(expected.r, 0.0001)
|
||||
assert_float(actual.g).is_equal_approx(expected.g, 0.0001)
|
||||
assert_float(actual.b).is_equal_approx(expected.b, 0.0001)
|
||||
assert_float(actual.a).is_equal_approx(expected.a, 0.0001)
|
||||
|
||||
|
||||
## The airless sentinel is a SKIP-CELL disposition (documented on
|
||||
## REGION_TEMP_NONE_DC and _draw_gen_region_grid) — the renderer never calls
|
||||
## region_temp_color() for it at all, so there is no "sentinel color" to
|
||||
## assert on. This test instead pins the sentinel's numeric identity, which
|
||||
## is what _draw_gen_region_grid's equality check depends on.
|
||||
func test_region_temp_none_sentinel_is_i16_min() -> void:
|
||||
assert_int(AtlasOverlayColors.REGION_TEMP_NONE_DC).is_equal(-32768)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1119: gen_l4_quarters overlay (quarter-footprint glyph, D-226 T-1112 amendment)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_quarter_footprints_overlay_registered() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(["gen_l4_quarters"])
|
||||
|
||||
|
||||
## QuarterFootprintLayer shape pinned to server/src/atlas/layer_proxy.rs
|
||||
## (D-226 T-1112 amendment SS1): entries keyed by city_id (BTreeMap<u64,_> on
|
||||
## the wire, decodes to a Dictionary with int keys), five scalar u8/enum
|
||||
## fields per entry, no per-block detail (SS2 hard ceiling).
|
||||
func test_quarter_footprints_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_quarter_footprints()).is_null()
|
||||
var footprints := {
|
||||
"entries":
|
||||
{
|
||||
1:
|
||||
{
|
||||
"city_id": 1,
|
||||
"density_avg_pct": 62,
|
||||
"dominant_district_type": "Commercial",
|
||||
"dominant_zoning": "Commercial",
|
||||
"landmark_count": 3,
|
||||
"corridor_count": 4,
|
||||
},
|
||||
2:
|
||||
{
|
||||
"city_id": 2,
|
||||
"density_avg_pct": 18,
|
||||
"dominant_district_type": "Residential",
|
||||
"dominant_zoning": "Residential",
|
||||
"landmark_count": 0,
|
||||
"corridor_count": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
v.set_generation_quarter_footprints(footprints)
|
||||
assert_that(v.get_generation_quarter_footprints()).is_equal(footprints)
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "quarter_footprints": footprints}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("quarter_footprints")).is_equal(footprints)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_glyph_size() — density-scaled, clamped, and
|
||||
## forced to minimum when the zoom gate (show_notch) is off regardless of
|
||||
## density (D-226 T-1112 amendment SS3: "below [the threshold], draws at
|
||||
## minimum size with color only").
|
||||
func test_quarter_glyph_size() -> void:
|
||||
# 0% density, notch shown -> base size.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(0, true)).is_equal_approx(
|
||||
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
|
||||
)
|
||||
# 100% density, notch shown -> max size (4.0 + 1.0*6.0 = 10.0, under the 12.0 cap).
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(100, true)).is_equal_approx(10.0, 0.001)
|
||||
# Below the zoom gate: always minimum, regardless of density.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(100, false)).is_equal_approx(
|
||||
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
|
||||
)
|
||||
# Fixture literals: city 1 (62%) and city 2 (18%), both notch-shown.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(62, true)).is_greater(
|
||||
AtlasOverlayColors.quarter_glyph_size(18, true)
|
||||
)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_glyph_color() — single-hue ramp within the
|
||||
## settlement-gold family, endpoints pinned to the D-226 T-1112 amendment
|
||||
## SS3's literal legend colors. Approx compare (see test_region_temp_color_ramp's
|
||||
## comment) — a lerp(a, b, 1.0) isn't guaranteed bit-exact to b for every
|
||||
## color pair, and exact equality shouldn't depend on which pair happens to
|
||||
## round losslessly.
|
||||
func test_quarter_glyph_color_ramp() -> void:
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.quarter_glyph_color(0), AtlasOverlayColors.COLOR_QUARTER_LOW_DENSITY
|
||||
)
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.quarter_glyph_color(100), AtlasOverlayColors.COLOR_QUARTER_HIGH_DENSITY
|
||||
)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_notch_kind() — the 3-variant-plus-plain cap
|
||||
## (D-226 T-1112 amendment SS3: "a coarse skeleton read, not a legend of
|
||||
## every DistrictType"). Every other DistrictType — including an unknown/
|
||||
## empty string — falls through to "plain".
|
||||
func test_quarter_notch_kind_selection() -> void:
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Commercial")).is_equal("commercial")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Industrial")).is_equal("industrial")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Administrative")).is_equal("administrative")
|
||||
for other in ["LogisticsHub", "Residential", "Entertainment", "MixedUse", "Transit", "Specialized"]:
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind(other)).override_failure_message(
|
||||
"DistrictType '%s' should read as plain (not one of the 3 marked variants)" % other
|
||||
).is_equal("plain")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("")).is_equal("plain")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("SomeUnrecognizedFutureVariant")).is_equal("plain")
|
||||
|
||||
|
||||
## Tier-2 replay: a REAL server-generated msgpack blob (server/tests/gen_fixtures.rs
|
||||
## generate_atlas_layer_response_fixtures, regenerated 2026-07-18 with T-960's
|
||||
## road_graph/settlements, T-1118's region_grid, and T-1119's
|
||||
## quarter_footprints all populated) decoded through the actual client path —
|
||||
## the strongest check that protocol.gd's decode matches the server's wire
|
||||
## encoding, not just a hand-authored Dictionary the client wrote itself.
|
||||
func test_atlas_response_ready_fixture_decodes_road_graph_and_settlements() -> void:
|
||||
var path := "res://tests/fixtures/msgpack/atlas_response_ready.msgpack"
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(f).is_not_null()
|
||||
var bytes := f.get_buffer(f.get_length())
|
||||
f.close()
|
||||
|
||||
var decoded: Variant = Protocol.decode_atlas_layer_response(bytes)
|
||||
assert_that(decoded).is_not_null()
|
||||
var response: Dictionary = decoded
|
||||
assert_str(response.get("body_id")).is_equal("GJ1c")
|
||||
assert_str(response.get("status")).is_equal("Ready")
|
||||
|
||||
var road_graph: Dictionary = response.get("road_graph")
|
||||
assert_that(road_graph).is_not_null()
|
||||
assert_int((road_graph.get("nodes", []) as Array).size()).is_equal(2)
|
||||
var edges: Array = road_graph.get("edges", [])
|
||||
assert_int(edges.size()).is_equal(1)
|
||||
assert_str((edges[0] as Dictionary).get("maintenance")).is_equal("Administrative")
|
||||
assert_bool((edges[0] as Dictionary).get("is_rail")).is_false()
|
||||
|
||||
var settlements: Dictionary = response.get("settlements")
|
||||
assert_that(settlements).is_not_null()
|
||||
var entries: Array = settlements.get("settlements", [])
|
||||
assert_int(entries.size()).is_equal(2)
|
||||
var capital: Dictionary = entries[0]
|
||||
assert_str(capital.get("name")).is_equal("Port Aldren")
|
||||
assert_str(capital.get("size_class")).is_equal("Major")
|
||||
assert_bool(capital.get("is_capital")).is_true()
|
||||
var minor: Dictionary = entries[1]
|
||||
assert_str(minor.get("name")).is_equal("Farmstead Rell")
|
||||
assert_str(minor.get("size_class")).is_equal("Minor")
|
||||
assert_bool(minor.get("is_capital")).is_false()
|
||||
|
||||
# T-1118: region_grid, a 2x1 grid (dudley-depth's fixture literal,
|
||||
# 2026-07-18) — col0 Summer/Clear/12.3C(123 deci-C)/moisture 80, col1
|
||||
# Winter/Snow/airless-sentinel/moisture 5. season/weather/mean_temp_dc/
|
||||
# moisture_q are all "dense array of ints" fields (rmp_serde Vec<u8>/
|
||||
# Vec<i16> with no serde_bytes, so NOT bin_8/16/32 on the wire) — read
|
||||
# element-wise via int() rather than asserting a specific Godot container
|
||||
# type, since the addon decodes a plain msgpack array as Array, not
|
||||
# PackedByteArray (see _dense_int helper below).
|
||||
var region_grid: Dictionary = response.get("region_grid")
|
||||
assert_that(region_grid).is_not_null()
|
||||
assert_int(int(region_grid.get("cols", 0))).is_equal(2)
|
||||
assert_int(int(region_grid.get("rows", 0))).is_equal(1)
|
||||
assert_int(_dense_int(region_grid.get("mean_temp_dc"), 0)).is_equal(123)
|
||||
assert_int(_dense_int(region_grid.get("mean_temp_dc"), 1)).is_equal(
|
||||
AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
)
|
||||
assert_int(_dense_int(region_grid.get("moisture_q"), 0)).is_equal(80)
|
||||
assert_int(_dense_int(region_grid.get("moisture_q"), 1)).is_equal(5)
|
||||
|
||||
# T-1119: quarter_footprints, keyed by city_id (BTreeMap<u64,_> on the
|
||||
# wire -> Dictionary with int keys). city_id 1 (Port Aldren, capital) gets
|
||||
# a "rich" entry; city_id 2 (Farmstead Rell, minor) gets a deliberately
|
||||
# sparse one (zero landmarks, one corridor) — both present, per
|
||||
# dudley-depth's fixture literal, exercising the subset-safety contract
|
||||
# (entries can legitimately be a SUBSET of settlements — not tested here
|
||||
# since both happen to be present in this fixture, but the accessor path
|
||||
# must not assume 1:1).
|
||||
var quarters: Dictionary = response.get("quarter_footprints")
|
||||
assert_that(quarters).is_not_null()
|
||||
var qf_entries: Dictionary = quarters.get("entries", {})
|
||||
assert_int(qf_entries.size()).is_equal(2)
|
||||
var rich: Dictionary = qf_entries[1]
|
||||
assert_int(int(rich.get("density_avg_pct", -1))).is_equal(62)
|
||||
assert_str(str(rich.get("dominant_district_type", ""))).is_equal("Commercial")
|
||||
assert_str(str(rich.get("dominant_zoning", ""))).is_equal("Commercial")
|
||||
assert_int(int(rich.get("landmark_count", -1))).is_equal(3)
|
||||
assert_int(int(rich.get("corridor_count", -1))).is_equal(4)
|
||||
var sparse: Dictionary = qf_entries[2]
|
||||
assert_int(int(sparse.get("density_avg_pct", -1))).is_equal(18)
|
||||
assert_str(str(sparse.get("dominant_district_type", ""))).is_equal("Residential")
|
||||
assert_str(str(sparse.get("dominant_zoning", ""))).is_equal("Residential")
|
||||
assert_int(int(sparse.get("landmark_count", -1))).is_equal(0)
|
||||
assert_int(int(sparse.get("corridor_count", -1))).is_equal(1)
|
||||
|
||||
|
||||
## pending/not_found fixtures carry region_grid/quarter_footprints as None too
|
||||
## (mirrors every other Option field's "unrun layer" treatment) — a quick
|
||||
## sanity check that the new fields don't silently break the OTHER two
|
||||
## fixtures' decode (they were regenerated in the same batch).
|
||||
func test_atlas_response_pending_and_not_found_fixtures_have_no_new_layers() -> void:
|
||||
for fixture_name in ["atlas_response_pending", "atlas_response_not_found"]:
|
||||
var path := "res://tests/fixtures/msgpack/%s.msgpack" % fixture_name
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(f).override_failure_message("missing fixture %s" % fixture_name).is_not_null()
|
||||
var bytes := f.get_buffer(f.get_length())
|
||||
f.close()
|
||||
var decoded: Variant = Protocol.decode_atlas_layer_response(bytes)
|
||||
assert_that(decoded).is_not_null()
|
||||
var response: Dictionary = decoded
|
||||
assert_that(response.get("region_grid")).override_failure_message(
|
||||
"%s should carry no region_grid" % fixture_name
|
||||
).is_null()
|
||||
assert_that(response.get("quarter_footprints")).override_failure_message(
|
||||
"%s should carry no quarter_footprints" % fixture_name
|
||||
).is_null()
|
||||
|
||||
|
||||
## Reads element `i` from a decoded "dense numeric array" field regardless of
|
||||
## whether the messagepack addon produced a PackedByteArray (bin_8/16/32) or a
|
||||
## plain Array (fixarray/array_16/array_32) — rmp_serde without serde_bytes
|
||||
## encodes Vec<u8>/Vec<i16> as the latter, so this is the defensively-correct
|
||||
## read for region_grid's season/weather/mean_temp_dc/moisture_q.
|
||||
static func _dense_int(arr: Variant, i: int) -> int:
|
||||
if arr is Array or arr is PackedByteArray:
|
||||
return int(arr[i])
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# D-226 item 3: generation legend panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Data-driven claim check: every legend entry must reference a real overlay
|
||||
## id, so a typo or a stale entry can't silently produce a dead legend
|
||||
## section that never appears.
|
||||
func test_generation_legend_entries_reference_real_overlay_ids() -> void:
|
||||
var overlay_ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
overlay_ids.append(d["id"])
|
||||
for spec: Dictionary in LegendPanelScript.GENERATION_LEGEND:
|
||||
assert_that(overlay_ids).contains([spec.get("overlay_id", "")])
|
||||
assert_bool((spec.get("rows", []) as Array).is_empty()).override_failure_message(
|
||||
"legend entry '%s' has no rows" % spec.get("title", "?")
|
||||
).is_false()
|
||||
|
||||
|
||||
## Every gen_* toggleable overlay should have at least one legend entry so
|
||||
## toggling it on never shows silent, unexplained map content.
|
||||
func test_every_gen_overlay_has_a_legend_entry() -> void:
|
||||
var legend_overlay_ids: Array = []
|
||||
for spec: Dictionary in LegendPanelScript.GENERATION_LEGEND:
|
||||
legend_overlay_ids.append(spec.get("overlay_id", ""))
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
var overlay_id: String = d["id"]
|
||||
if overlay_id.begins_with("gen_"):
|
||||
assert_that(legend_overlay_ids).override_failure_message(
|
||||
"overlay '%s' has no GENERATION_LEGEND entry" % overlay_id
|
||||
).contains([overlay_id])
|
||||
|
||||
|
||||
## Full-lifecycle test (add_child fires _ready(), which builds the legend
|
||||
## panel) — invisible at rest, since no generation overlay starts active.
|
||||
func test_legend_panel_hidden_by_default() -> void:
|
||||
var v: AtlasViewer = AtlasViewer.new()
|
||||
add_child(v)
|
||||
assert_bool(v._legend_panel.visible).override_failure_message(
|
||||
"legend panel must be invisible when no generation overlay is active"
|
||||
).is_false()
|
||||
v.queue_free()
|
||||
|
||||
|
||||
## Toggling a gen_* overlay on must reveal the legend and populate its rows;
|
||||
## toggling it back off must hide it again (invisible-when-not-needed, D-226).
|
||||
func test_legend_panel_shows_active_overlay_and_hides_when_toggled_off() -> void:
|
||||
var v: AtlasViewer = AtlasViewer.new()
|
||||
add_child(v)
|
||||
|
||||
v.set_overlay_visible("gen_l3_settlements", true)
|
||||
assert_bool(v._legend_panel.visible).override_failure_message(
|
||||
"legend panel must show once a generation overlay is toggled on"
|
||||
).is_true()
|
||||
assert_int(v._legend_panel.get_implant_children().size()).override_failure_message(
|
||||
"legend panel must have content rows once populated"
|
||||
).is_greater(0)
|
||||
|
||||
v.set_overlay_visible("gen_l3_settlements", false)
|
||||
assert_bool(v._legend_panel.visible).override_failure_message(
|
||||
"legend panel must hide again once its only active overlay is toggled off"
|
||||
).is_false()
|
||||
|
||||
v.queue_free()
|
||||
|
||||
|
||||
## PR #186 regression: the overlay bar spans the full header-adjacent width
|
||||
## so rows can wrap (ALIGNMENT_END right-aligns the chips), which leaves a
|
||||
## wide EMPTY strip inside the container rect on wide screens. That strip
|
||||
## must never eat map input: the container is MOUSE_FILTER_IGNORE (chip
|
||||
## Buttons STOP their own events) and _is_over_ui() must not treat the bar
|
||||
## rect as UI — both are pinned here because no other test exercises them.
|
||||
func test_overlay_bar_empty_area_does_not_block_map_input() -> void:
|
||||
var v: AtlasViewer = AtlasViewer.new()
|
||||
add_child(v)
|
||||
v.size = Vector2(1920.0, 1080.0)
|
||||
v._position_overlay_bar()
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_int(v._overlay_bar.mouse_filter).override_failure_message(
|
||||
"overlay bar container must be MOUSE_FILTER_IGNORE — STOP turns the"
|
||||
+ " empty flow area into a dead strip that swallows map clicks"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
# A point just inside the bar's top-left is empty flow area (chips are
|
||||
# right-aligned and occupy well under the full width at 1920px).
|
||||
var strip_point: Vector2 = v._overlay_bar.global_position + Vector2(8.0, 8.0)
|
||||
assert_bool(v._is_over_ui(strip_point)).override_failure_message(
|
||||
"_is_over_ui must not claim the overlay bar's empty strip — map"
|
||||
+ " pan/click near the top edge would silently die there"
|
||||
).is_false()
|
||||
|
||||
v.queue_free()
|
||||
@@ -1,71 +0,0 @@
|
||||
## T-1120: tests for AtlasViewer's public view-transform API (set_view() /
|
||||
## get_view_offset(), paired with the pre-existing get_view_zoom()).
|
||||
##
|
||||
## Added for the Atlas screenshot capture matrix harness, which needs to set
|
||||
## deterministic zoom/pan without simulating mouse wheel events against
|
||||
## viewer internals (_view_zoom/_view_offset were previously private-only,
|
||||
## review flagged reaching into them from visual_capture.gd as the wrong
|
||||
## layer to poke at — this is the public surface instead).
|
||||
class_name TestAtlasViewApi
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func test_set_view_applies_zoom_and_offset() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v) # _canvas resolves in _ready(); a bare .new() crashes _apply_transform
|
||||
v.set_view(3.0, Vector2(100.0, -40.0))
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001)
|
||||
assert_that(v.get_view_offset()).is_equal(Vector2(100.0, -40.0))
|
||||
|
||||
|
||||
## MIN_ZOOM/MAX_ZOOM are 0.5/8.0 (atlas_viewer.gd) — set_view() must clamp the
|
||||
## same way _zoom_at()/_fit_to_view() do, so a caller can't push the view into
|
||||
## a range the rest of the viewer doesn't expect.
|
||||
func test_set_view_clamps_zoom_to_min_max() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
|
||||
v.set_view(0.01, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasViewer.MIN_ZOOM, 0.001)
|
||||
|
||||
v.set_view(1000.0, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasViewer.MAX_ZOOM, 0.001)
|
||||
|
||||
|
||||
## Getter round-trip: what set_view() writes is exactly what get_view_zoom()/
|
||||
## get_view_offset() read back (within the clamp), across a few values —
|
||||
## guards against an off-by-transform bug (e.g. accidentally reading
|
||||
## _canvas.scale/_canvas.position instead of the cached _view_zoom/_view_offset).
|
||||
func test_view_getters_round_trip_after_set_view() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
|
||||
var cases: Array = [
|
||||
[1.0, Vector2.ZERO],
|
||||
[2.0, Vector2(50.0, 25.0)],
|
||||
[4.0, Vector2(-200.0, 300.0)],
|
||||
[AtlasViewer.MIN_ZOOM, Vector2(10.0, 10.0)],
|
||||
[AtlasViewer.MAX_ZOOM, Vector2(-10.0, -10.0)],
|
||||
]
|
||||
for c: Array in cases:
|
||||
var zoom: float = c[0]
|
||||
var offset: Vector2 = c[1]
|
||||
v.set_view(zoom, offset)
|
||||
assert_that(v.get_view_zoom()).override_failure_message(
|
||||
"zoom round-trip failed for input %.2f" % zoom
|
||||
).is_equal_approx(zoom, 0.001)
|
||||
assert_that(v.get_view_offset()).override_failure_message(
|
||||
"offset round-trip failed for input %s" % offset
|
||||
).is_equal(offset)
|
||||
|
||||
|
||||
## set_view() must drive the same _canvas transform _apply_transform() drives
|
||||
## for mouse-wheel zoom/drag pan — otherwise the harness would set state the
|
||||
## getters report correctly but the viewport never actually renders.
|
||||
func test_set_view_updates_canvas_transform() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v.set_view(2.5, Vector2(15.0, -5.0))
|
||||
var canvas: Node2D = v.get_node("AtlasCanvas")
|
||||
assert_that(canvas.scale).is_equal(Vector2(2.5, 2.5))
|
||||
assert_that(canvas.position).is_equal(Vector2(15.0, -5.0))
|
||||
@@ -1,7 +1,14 @@
|
||||
## T-1138 (D-226 T-1124 amendment §5): pure color-ramp tests for the
|
||||
## regional-window base layer + toggle overlays. Every function under test
|
||||
## lives on atlas_overlay_colors.gd (no class_name — preloaded by path,
|
||||
## matching test_atlas_overlays.gd's own AtlasOverlayColors const).
|
||||
## lives on atlas_overlay_colors.gd (no class_name — preloaded by path).
|
||||
##
|
||||
## T-1182 PR #203 review (Tyre finding — the AtlasViewer cluster orphan
|
||||
## retirement): the region_temp/quarter_glyph/quarter_notch sections below
|
||||
## were RELOCATED here from the deleted test_atlas_overlays.gd — that suite
|
||||
## tested atlas_viewer.gd/atlas_legend_panel.gd round-trips (dead once the
|
||||
## orphaned cluster was deleted), but five of its tests exercised pure
|
||||
## AtlasOverlayColors functions that survive the retirement untouched. Moved
|
||||
## verbatim, not rewritten — same assertions, same rationale comments.
|
||||
class_name TestAtlasWindowColors
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -168,3 +175,106 @@ func _assert_color_approx(actual: Color, expected: Color) -> void:
|
||||
## it would just add a constant offset with no signal).
|
||||
static func _color_distance(a: Color, b: Color) -> float:
|
||||
return Vector3(a.r, a.g, a.b).distance_to(Vector3(b.r, b.g, b.b))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Relocated from test_atlas_overlays.gd (T-1182 PR #203 review) — region
|
||||
# temperature ramp (T-1118, region_grid overlay's one visual channel).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## AtlasOverlayColors.region_temp_color() pure ramp — the one visual channel
|
||||
## the region_grid overlay ships (mean temp only; season/weather/moisture
|
||||
## deferred). Compares components with is_equal_approx() rather than whole-
|
||||
## Color is_equal(): Godot's Color.lerp(a, b, 1.0) is NOT bit-exact to b
|
||||
## (confirmed empirically — the two print identically but == is false at the
|
||||
## ULP level), so an exact Color equality check is the wrong tool at a lerp
|
||||
## boundary regardless of whether the ramp math itself is correct.
|
||||
func test_region_temp_color_ramp() -> void:
|
||||
# Cold end clamps to pure cold color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MIN_DC),
|
||||
AtlasOverlayColors.COLOR_REGION_TEMP_COLD
|
||||
)
|
||||
# Hot end clamps to pure hot color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MAX_DC),
|
||||
AtlasOverlayColors.COLOR_REGION_TEMP_HOT
|
||||
)
|
||||
# Midpoint (0.0 C) lands on the mid color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(0), AtlasOverlayColors.COLOR_REGION_TEMP_MID
|
||||
)
|
||||
# Out-of-band readings clamp rather than extrapolate past the endpoints.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(-9999), AtlasOverlayColors.COLOR_REGION_TEMP_COLD
|
||||
)
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(9999), AtlasOverlayColors.COLOR_REGION_TEMP_HOT
|
||||
)
|
||||
|
||||
|
||||
## The airless sentinel is a SKIP-CELL disposition — a renderer never calls
|
||||
## region_temp_color() for it at all, so there is no "sentinel color" to
|
||||
## assert on. This pins the sentinel's numeric identity instead, which is
|
||||
## what a caller's equality check depends on.
|
||||
func test_region_temp_none_sentinel_is_i16_min() -> void:
|
||||
assert_int(AtlasOverlayColors.REGION_TEMP_NONE_DC).is_equal(-32768)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Relocated from test_atlas_overlays.gd (T-1182 PR #203 review) — quarter
|
||||
# glyph size/color ramp + notch-kind mapping (T-1119, D-226 T-1112 amendment).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_glyph_size() — density-scaled, clamped, and
|
||||
## forced to minimum when the zoom gate (show_notch) is off regardless of
|
||||
## density (D-226 T-1112 amendment §3: "below [the threshold], draws at
|
||||
## minimum size with color only").
|
||||
func test_quarter_glyph_size() -> void:
|
||||
# 0% density, notch shown -> base size.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(0, true)).is_equal_approx(
|
||||
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
|
||||
)
|
||||
# 100% density, notch shown -> max size (4.0 + 1.0*6.0 = 10.0, under the 12.0 cap).
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(100, true)).is_equal_approx(10.0, 0.001)
|
||||
# Below the zoom gate: always minimum, regardless of density.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(100, false)).is_equal_approx(
|
||||
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
|
||||
)
|
||||
# Fixture literals: city 1 (62%) and city 2 (18%), both notch-shown.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(62, true)).is_greater(
|
||||
AtlasOverlayColors.quarter_glyph_size(18, true)
|
||||
)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_glyph_color() — single-hue ramp within the
|
||||
## settlement-gold family, endpoints pinned to the D-226 T-1112 amendment
|
||||
## §3's literal legend colors. Approx compare (see test_region_temp_color_ramp's
|
||||
## comment) — a lerp(a, b, 1.0) isn't guaranteed bit-exact to b for every
|
||||
## color pair, and exact equality shouldn't depend on which pair happens to
|
||||
## round losslessly.
|
||||
func test_quarter_glyph_color_ramp() -> void:
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.quarter_glyph_color(0), AtlasOverlayColors.COLOR_QUARTER_LOW_DENSITY
|
||||
)
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.quarter_glyph_color(100), AtlasOverlayColors.COLOR_QUARTER_HIGH_DENSITY
|
||||
)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_notch_kind() — the 3-variant-plus-plain cap
|
||||
## (D-226 T-1112 amendment §3: "a coarse skeleton read, not a legend of every
|
||||
## DistrictType"). Every other DistrictType — including an unknown/empty
|
||||
## string — falls through to "plain".
|
||||
func test_quarter_notch_kind_selection() -> void:
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Commercial")).is_equal("commercial")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Industrial")).is_equal("industrial")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Administrative")).is_equal("administrative")
|
||||
for other in ["LogisticsHub", "Residential", "Entertainment", "MixedUse", "Transit", "Specialized"]:
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind(other)).override_failure_message(
|
||||
"DistrictType '%s' should read as plain (not one of the 3 marked variants)" % other
|
||||
).is_equal("plain")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("")).is_equal("plain")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("SomeUnrecognizedFutureVariant")).is_equal("plain")
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
## T-1182 tests: StepCanvasLegend — smoke coverage (PR #203 review, Hoshe
|
||||
## note). Not the retired atlas_window_legend.gd's own richer suite (that
|
||||
## file never had a dedicated test — its coverage lived entirely inside
|
||||
## AtlasWindowViewer's own suites, now retired); this pins the panel's basic
|
||||
## lifecycle/content contract directly.
|
||||
class_name TestStepCanvasLegend
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const LegendScript := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd")
|
||||
|
||||
|
||||
func test_legend_starts_hidden_before_refresh() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
var legend = LegendScript.new(v)
|
||||
auto_free(legend)
|
||||
assert_bool(legend.visible).is_false()
|
||||
|
||||
|
||||
func test_refresh_makes_the_legend_visible_and_populates_content() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
var legend = LegendScript.new(v)
|
||||
add_child(legend)
|
||||
legend.refresh()
|
||||
assert_bool(legend.visible).is_true()
|
||||
assert_int(legend.get_implant_children().size()).override_failure_message(
|
||||
"refresh() must populate at least the always-on morphology/glaciation sections"
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
## refresh() is a no-op (does not crash) when the legend has no viewer —
|
||||
## defensive guard against a construction-order mistake.
|
||||
func test_refresh_without_a_viewer_does_not_crash() -> void:
|
||||
var legend = LegendScript.new(null)
|
||||
auto_free(legend)
|
||||
legend.refresh()
|
||||
assert_bool(legend.visible).is_false()
|
||||
|
||||
|
||||
## Toggling a gen_* overlay on changes the legend's content (the active
|
||||
## toggle section appears) without crashing — the same "no active toggle by
|
||||
## default, one section per active toggle" contract the retired
|
||||
## atlas_window_legend.gd's own refresh() doc described.
|
||||
func test_refresh_reflects_the_active_overlay_toggle() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
var legend = LegendScript.new(v)
|
||||
add_child(legend)
|
||||
legend.refresh()
|
||||
var rows_before: int = legend.get_implant_children().size()
|
||||
|
||||
v.set_overlay_visible("gen_dw_temp", true)
|
||||
legend.refresh()
|
||||
var rows_after: int = legend.get_implant_children().size()
|
||||
|
||||
assert_int(rows_after).override_failure_message(
|
||||
"activating a toggle overlay must add its own legend section"
|
||||
).is_greater(rows_before)
|
||||
|
||||
|
||||
func test_reposition_sets_a_fixed_panel_margin_position() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
var legend = LegendScript.new(v)
|
||||
auto_free(legend)
|
||||
legend.reposition()
|
||||
assert_that(legend.position).is_equal(Vector2(LegendScript.PANEL_MARGIN, 60.0))
|
||||
@@ -210,3 +210,37 @@ func test_decode_png_field_malformed_input_returns_empty_packed_byte_array() ->
|
||||
assert_that(Protocol._scp().decode_png_field({"png_bytes": "not an array"})).is_equal(
|
||||
PackedByteArray()
|
||||
)
|
||||
|
||||
|
||||
## PR #203 review (Hoshe finding 1) — the genuine msgpack `bin_8`/`bin_16`/
|
||||
## `bin_32` decode path. messagepack.gd's own decoder returns
|
||||
## `StreamPeerBuffer.get_partial_data()`'s `[Error, PackedByteArray]` pair for
|
||||
## a bin-typed field — which passes decode_png_field()'s `is Array` guard
|
||||
## just as readily as the real (today's) array-of-ints shape, so this must be
|
||||
## exercised with the REAL bin-shaped decode output, not a hand-built
|
||||
## Dictionary, to prove the fix actually detects it. Built by round-tripping
|
||||
## a genuine PackedByteArray value through Messagepack.encode()/decode()
|
||||
## directly (bypassing step_canvas_protocol.gd's own encoder, which never
|
||||
## sends a PackedByteArray for png_bytes today) — this is exactly the shape
|
||||
## a future serde_bytes-annotated server would produce.
|
||||
func test_decode_png_field_handles_the_genuine_bin_type_shape() -> void:
|
||||
var real_png_bytes := PackedByteArray([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
||||
var encoded = Messagepack.encode(real_png_bytes)
|
||||
assert_that(encoded.status).is_null()
|
||||
var decoded = Messagepack.decode(encoded.value)
|
||||
assert_that(decoded.status).is_null()
|
||||
# Sanity: this really is the [error, data] pair shape, not the plain-array
|
||||
# shape the rest of this suite exercises — if messagepack.gd's own
|
||||
# behavior ever changes, this assertion fails loudly instead of the test
|
||||
# silently exercising the wrong code path.
|
||||
var bin_shape: Variant = decoded.value
|
||||
assert_bool(bin_shape is Array).is_true()
|
||||
assert_int((bin_shape as Array).size()).is_equal(2)
|
||||
assert_bool((bin_shape as Array)[0] is int).is_true()
|
||||
assert_bool((bin_shape as Array)[1] is PackedByteArray).is_true()
|
||||
|
||||
var result: PackedByteArray = Protocol._scp().decode_png_field({"png_bytes": bin_shape})
|
||||
assert_that(result).override_failure_message(
|
||||
"bin-shaped png_bytes must decode to the REAL inner bytes, not silently"
|
||||
+ " collapse to [0, 0] via PackedByteArray([int, PackedByteArray]) coercion"
|
||||
).is_equal(real_png_bytes)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
## T-1182 tests: StepCanvasViewer — the rung transport state machine
|
||||
## (enter() lands on the Global opener, scroll steps through the ladder,
|
||||
## overlay toggle wiring) 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, matching test_atlas_view_api.gd's own
|
||||
## no-live-server convention for viewer-internals tests.
|
||||
## 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
|
||||
|
||||
@@ -55,6 +55,47 @@ func test_reset_to_global_returns_from_a_deep_rung() -> void:
|
||||
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
||||
|
||||
|
||||
## PR #203 review (Hoshe finding 2): the hard full-zoom-out reset — a
|
||||
## scroll-out gesture while ALREADY at Global (rung 0), with the view
|
||||
## drifted from the canonical un-panned frame, must snap the view back to
|
||||
## center (Jeroen's explicit HARD condition, carried from the retired
|
||||
## viewer's own _maybe_reset_to_canonical_frame()). Behavioral, through the
|
||||
## real input entry point (_scroll_rung with direction=-1), not a direct
|
||||
## _reset_to_global() call — this is what would have caught the dead-code
|
||||
## regression (scroll_step() clamping at index 0 meant _scroll_rung()
|
||||
## returned before ever reaching a reset call).
|
||||
func test_scroll_out_at_global_after_a_pan_resets_the_view() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
||||
|
||||
v._apply_pan_delta(Vector2(1.0, 0.0), 1.0) # drift the view off-center
|
||||
assert_bool(v._is_global_view_drifted()).override_failure_message(
|
||||
"test setup: a pan at Global must actually drift the view"
|
||||
).is_true()
|
||||
|
||||
v._scroll_rung(-1, Vector2(400.0, 300.0)) # scroll OUT — already at rung 0
|
||||
|
||||
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
||||
assert_bool(v._is_global_view_drifted()).override_failure_message(
|
||||
"a scroll-out past the top of the ladder must hard-reset the drifted"
|
||||
+ " Global view back to its canonical (centered) frame"
|
||||
).is_false()
|
||||
|
||||
|
||||
## The inverse guard: scrolling out while ALREADY at the canonical
|
||||
## (un-drifted) Global frame must stay a no-op — the reset is edge-triggered
|
||||
## on genuine drift, not a per-scroll unconditional reset.
|
||||
func test_scroll_out_at_undrifted_global_is_a_no_op() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(-1, Vector2(400.0, 300.0))
|
||||
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
|
||||
assert_bool(v._is_global_view_drifted()).is_false()
|
||||
|
||||
|
||||
func test_overlay_visibility_defaults_to_off_for_every_toggle() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
@@ -108,3 +149,179 @@ func test_regional_screen_different_body_still_re_enters() -> void:
|
||||
screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}})
|
||||
|
||||
assert_str(screen._viewer.get_body_id()).is_equal("OtherBody")
|
||||
|
||||
|
||||
## Relocated from test_atlas_descend_entry.gd (T-1182 PR #203 review — the
|
||||
## AtlasViewer cluster orphan retirement, Tyre finding). This is the one live
|
||||
## regression guard from that suite: RegionalScreen must wrap StepCanvasViewer
|
||||
## (the stepped ladder), never fall back to the now-deleted AtlasViewer
|
||||
## heightmap-texture display — a direct type-identity check, distinct from
|
||||
## the behavioral tests above (which would only fail indirectly, via a
|
||||
## missing method, if this ever regressed).
|
||||
func test_regional_screen_wraps_step_canvas_viewer_not_atlas_viewer() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
assert_object(screen._viewer).override_failure_message(
|
||||
"RegionalScreen must wrap StepCanvasViewer (the stepped ladder) since T-1182,"
|
||||
+ " not the retired AtlasViewer heightmap-texture display"
|
||||
).is_instanceof(StepCanvasViewer)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PR #203 review (Hoshe notes): pan-edge re-request (_maybe_refloat) and
|
||||
# edge-scroll pan — previously untested. _maybe_refloat() only does anything
|
||||
# once the terrain layer holds a real texture (get_footprint_px() is
|
||||
# ZERO/inert until then), so these tests drive StepCanvasTerrainLayer.
|
||||
# rebuild_from_canvas() directly (bypassing the network — a decoded canvas
|
||||
# dict is all it needs) to put the viewer into the "holding a real canvas"
|
||||
# state _maybe_refloat's early-out guards against.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
static func _synthetic_canvas(width: int, height: int) -> Dictionary:
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"morphology": null,
|
||||
"elev_q": null,
|
||||
"moisture_q": null,
|
||||
"vegetation": null,
|
||||
"glaciation": null,
|
||||
"temp_dc": [],
|
||||
"settlement_id": [],
|
||||
"courses": [],
|
||||
"cliffs": [],
|
||||
}
|
||||
|
||||
|
||||
func test_maybe_refloat_is_inert_before_any_canvas_has_arrived() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # District — footprint still ZERO, nothing arrived
|
||||
var world_center_before: Vector2 = v._world_center
|
||||
v._maybe_refloat()
|
||||
assert_that(v._world_center).is_equal(world_center_before)
|
||||
|
||||
|
||||
## A small pan (well under half the canvas footprint) must NOT re-float —
|
||||
## the held canvas keeps drawing, no re-request (§4/§5's "only when a pan
|
||||
## carries the view past the held window's edge").
|
||||
func test_maybe_refloat_does_not_refloat_on_a_small_pan() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0) # a real viewport size — _maybe_refloat's
|
||||
# drift math is relative to get_rect().size's own center; leaving this at
|
||||
# the default ZERO would make screen_center ZERO too, so even a tiny
|
||||
# view_offset reads as "drifted past the canvas's own half-footprint"
|
||||
# (drift = view_offset + half, threshold = half*0.5) — a test-harness
|
||||
# artifact, not the behavior under test.
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
|
||||
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(64, 64), v.get_held_rung(), "")
|
||||
# _scroll_rung() re-centers view_offset to ZERO on arrival, which under a
|
||||
# real 800x600 viewport already puts the canvas center near screen center
|
||||
# (both small relative to the viewport) — center the canvas explicitly so
|
||||
# "small pan" starts from a known-centered baseline.
|
||||
v._view_offset = v.size * 0.5 - v._terrain_layer.get_footprint_px() * 0.5
|
||||
|
||||
var world_center_before: Vector2 = v._world_center
|
||||
v._view_offset += Vector2(2.0, 0.0) # tiny drift, far under half the footprint
|
||||
v._maybe_refloat()
|
||||
|
||||
assert_that(v._world_center).override_failure_message(
|
||||
"a small pan must not re-float the held canvas"
|
||||
).is_equal(world_center_before)
|
||||
|
||||
|
||||
## A large pan (past half the canvas footprint) DOES re-float — new
|
||||
## world_center, view_offset reset to ZERO (the canvas re-centers under the
|
||||
## new request).
|
||||
func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
|
||||
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(64, 64), v.get_held_rung(), "")
|
||||
|
||||
var world_center_before: Vector2 = v._world_center
|
||||
var footprint: Vector2 = v._terrain_layer.get_footprint_px()
|
||||
v._view_offset = Vector2(footprint.x, 0.0) # far past half the footprint
|
||||
v._maybe_refloat()
|
||||
|
||||
assert_that(v._world_center).override_failure_message(
|
||||
"a pan past the edge threshold must re-float (new world_center)"
|
||||
).is_not_equal(world_center_before)
|
||||
assert_that(v._view_offset).override_failure_message(
|
||||
"re-floating resets view_offset to ZERO (the canvas re-centers)"
|
||||
).is_equal(Vector2.ZERO)
|
||||
|
||||
|
||||
## Global never re-floats on pan (D-255(a): its canvas is the whole body,
|
||||
## no edge to cross) — even with a real texture held and a huge drift.
|
||||
func test_maybe_refloat_is_a_no_op_at_global_rung() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(200, 100), v.get_held_rung(), "")
|
||||
|
||||
var world_center_before: Vector2 = v._world_center
|
||||
v._view_offset = Vector2(9_999.0, 9_999.0)
|
||||
v._maybe_refloat()
|
||||
|
||||
assert_that(v._world_center).is_equal(world_center_before)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Edge-scroll: suppression conditions + direction.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_edge_scroll_suppressed_without_application_focus() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._app_has_focus = false
|
||||
v._last_mouse_pos = Vector2(2.0, 300.0) # well inside the edge margin
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_false()
|
||||
|
||||
|
||||
func test_edge_scroll_suppressed_when_cursor_has_never_moved_over_the_control() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
# _last_mouse_pos defaults to (-1, -1) — an impossible in-bounds position,
|
||||
# so edge-scroll never fires before the mouse has moved over the control
|
||||
# at least once (matches the retired viewer's own documented contract).
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_false()
|
||||
|
||||
|
||||
func test_edge_scroll_active_near_the_left_edge() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._app_has_focus = true
|
||||
v._last_mouse_pos = Vector2(2.0, 300.0)
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_true()
|
||||
var direction: Vector2 = v._edge_scroll_direction()
|
||||
assert_float(direction.x).is_less(0.0)
|
||||
assert_float(direction.y).is_equal(0.0)
|
||||
|
||||
|
||||
func test_edge_scroll_active_near_the_right_edge() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._app_has_focus = true
|
||||
v._last_mouse_pos = Vector2(798.0, 300.0)
|
||||
var direction: Vector2 = v._edge_scroll_direction()
|
||||
assert_float(direction.x).is_greater(0.0)
|
||||
|
||||
|
||||
func test_edge_scroll_inactive_well_inside_the_viewport() -> void:
|
||||
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._app_has_focus = true
|
||||
v._last_mouse_pos = Vector2(400.0, 300.0) # dead center — far from any edge
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_false()
|
||||
|
||||
Reference in New Issue
Block a user