Item 1: while zero tiles have arrived, the viewer draws a centered screen-space 'DERIVING TERRAIN…' label (text_dim role, no new hue), dropping the instant the first tile lands — a cold wait now reads as loading, not broken. New has_any_tile_arrived() predicate (distinct from has_pending_tiles(): both true mid-arrival, tested exactly there). Item 2: legend re-fit root-caused empirically, two plausible fixes disproven by trace before the real one: a manually-positioned Control's size NEVER tracks a shrinking minimum in this parenting shape, and RichTextLabel.fit_content reports degenerate minimums until laid out at real width once — so reset must be DEFERRED and run after refill, not inside clear(). ImplantPanel.reset_to_content_size() (call_deferred), wired into both legend refresh()es. The load-bearing test compares size.y to get_minimum_size().y — a size-to-size comparison passed trivially with both numbers equally stuck (caught on first draft). Item 3: make atlas now builds the RELEASE server and passes SR_SERVER_BIN (a cold DEBUG server delivers zero tiles for >10s on a new body — live-measured — vs 210ms warm; release serves cold in well under a second). atlas_standalone._server_binary_path() honors the env override per the SR_PORT two-tier precedent, debug path unchanged when unset. All fixes revert-verified; nine suites green collateral-checked; gdlint clean.
312 lines
15 KiB
GDScript
312 lines
15 KiB
GDScript
## PR #192 cold-start dossier — coordinator's live repro against a freshly-
|
|
## spawned (cold) server (`make atlas` shape, first AnalyzeBody taking
|
|
## seconds): BUG 1 (tile-mosaic paint never resolving), the legend-stacking
|
|
## half of BUG 2, and BUG 3 (the "DERIVING TERRAIN…" pending-state label,
|
|
## round 2). Split out of test_atlas_zoom_ladder.gd purely for file-length
|
|
## reasons (gdlint max-file-lines) — same instantiation/mock-response
|
|
## conventions as that file, not a different testing philosophy.
|
|
## RegionalScreen's own re-entry-guard half of BUG 2 is covered separately
|
|
## in test_regional_screen.gd (a different layer — nav, not the viewer).
|
|
class_name TestAtlasColdStart
|
|
extends GdUnitTestSuite
|
|
|
|
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
|
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
|
|
|
## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors
|
|
## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the
|
|
## real wire value matters, not a convenient placeholder).
|
|
const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295
|
|
|
|
|
|
## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors
|
|
## test_atlas_zoom_ladder.gd's own _mock_window().
|
|
static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary:
|
|
return {
|
|
"center": [center.x, center.y],
|
|
"n": n,
|
|
"morphology": PackedByteArray([8, 14, 0, 1]),
|
|
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
|
"temp_dc": [120, 95, -32768, 60],
|
|
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
|
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
|
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
|
}
|
|
|
|
|
|
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
|
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
|
|
|
|
|
# =============================================================================
|
|
# BUG 1 — tile-mosaic paint self-heal.
|
|
# =============================================================================
|
|
|
|
|
|
## Counts real _draw() invocations — CanvasItem exposes no public
|
|
## "is a redraw pending" query in this Godot version, so the only reliable
|
|
## signal that queue_redraw() actually had an effect is the engine calling
|
|
## _draw() again on a subsequent frame. Subclasses the REAL AtlasWindowOverlay
|
|
## (not a duck-typed stub) so drawing still runs through the genuine
|
|
## production code path — this spy only adds counting, nothing else.
|
|
class _CountingOverlay extends AtlasWindowOverlay:
|
|
var draw_count := 0
|
|
|
|
func _draw() -> void:
|
|
draw_count += 1
|
|
super._draw()
|
|
|
|
|
|
## On a cold server, a tile's window_ready can land well after entry's own
|
|
## paint window, and a live repro showed the mosaic staying black even with
|
|
## every tile held/textured — only resolving on an unrelated gesture.
|
|
## _process() must therefore queue a redraw on BOTH the viewer and the
|
|
## overlay every frame while any tile is still pending, regardless of
|
|
## whether the tile-arrival signal path painted correctly on its own. Proven
|
|
## here by swapping the REAL overlay for a _draw()-counting subclass right
|
|
## after entry (once the entry-time redraw has already resolved via a real
|
|
## frame), then calling _process() directly with NO input/gesture and
|
|
## confirming a further frame actually invokes _draw() again — revert-
|
|
## verified against a version of _process() with the self-heal removed
|
|
## (fails without it, since nothing else re-queues while idle).
|
|
func test_process_self_heals_the_overlay_redraw_while_tiles_are_pending() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling
|
|
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
|
assert_bool(v.is_tile_mode()).is_true()
|
|
assert_bool(v.get_tile_set().has_pending_tiles()).override_failure_message(
|
|
"sanity: entry must leave every tile pending before any response arrives"
|
|
).is_true()
|
|
|
|
# Swap in the counting spy AFTER entry (so entry's own queue_redraw()
|
|
# calls don't pollute the baseline) but the OLD overlay is freed and the
|
|
# spy re-added under the same _canvas parent, matching _ready()'s own
|
|
# construction shape exactly.
|
|
var spy := _CountingOverlay.new()
|
|
spy.viewer = v
|
|
v._overlay_node.queue_free()
|
|
v._overlay_node = spy
|
|
v._canvas.add_child(spy)
|
|
|
|
await get_tree().process_frame # let this frame settle with the spy in place
|
|
await get_tree().process_frame
|
|
var baseline: int = spy.draw_count
|
|
assert_int(baseline).override_failure_message(
|
|
"sanity: the spy must have been drawn at least once before the no-input"
|
|
+ " frame below, or this test can't distinguish self-heal from a first draw"
|
|
).is_greater(0)
|
|
|
|
# No pan/zoom/gesture — the ONLY thing that should cause another _draw()
|
|
# is _process()'s own self-heal, since has_pending_tiles() is still true
|
|
# (no response has been delivered).
|
|
v._process(0.016)
|
|
await get_tree().process_frame
|
|
|
|
assert_int(spy.draw_count).override_failure_message(
|
|
"_process() must queue a redraw every frame while has_pending_tiles()"
|
|
+ " is true, with NO input/gesture — draw_count must have advanced past"
|
|
+ " the baseline (%d), the cold-start self-heal" % baseline
|
|
).is_greater(baseline)
|
|
|
|
|
|
## The self-heal must STOP once every tile has arrived — a redraw queued
|
|
## forever regardless of state would just be a disguised always-redraw, not
|
|
## a targeted fix for the pending window.
|
|
func test_process_stops_self_healing_once_every_tile_has_arrived() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
var radius_km := 6238.4 # GJ380c (Lendel)
|
|
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
|
var tile_set = v.get_tile_set()
|
|
for tile: Dictionary in tile_set.get_tiles():
|
|
var window: Dictionary = _mock_window(tile["center"])
|
|
window["granularity_v2"] = "Region"
|
|
window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL
|
|
window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
assert_bool(tile_set.has_pending_tiles()).override_failure_message(
|
|
"sanity: every tile must have arrived after this loop"
|
|
).is_false()
|
|
|
|
|
|
# =============================================================================
|
|
# BUG 2 — legend stacking (the viewer-level half; see test_regional_screen.gd
|
|
# for the nav-layer re-entry guard).
|
|
# =============================================================================
|
|
|
|
|
|
## Coordinator's live scene dump: WindowLegend measured 260x2343 px, ~10
|
|
## legends stacked — ImplantPanel.clear() used deferred queue_free(), so
|
|
## same-frame repeat refresh() calls piled new content onto STALE not-yet-
|
|
## freed children instead of replacing them. N refresh() calls in the SAME
|
|
## frame (no process_frame between them, matching how the actual trigger —
|
|
## RegionalScreen.enter() previously lacking its own re-entry guard — landed
|
|
## repeat enter_orbital() calls back to back) must leave exactly ONE legend's
|
|
## worth of children, not N stacked copies.
|
|
func test_legend_refresh_is_idempotent_against_same_frame_re_entry() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
|
|
var baseline_count: int = v._legend_panel.get_implant_children().size()
|
|
for _i in range(10):
|
|
v._legend_panel.refresh()
|
|
var after_count: int = v._legend_panel.get_implant_children().size()
|
|
|
|
assert_int(after_count).override_failure_message(
|
|
(
|
|
"10 same-frame refresh() calls must leave exactly ONE legend's worth of"
|
|
+ " children (%d), not %d stacked copies — ImplantPanel.clear() must"
|
|
+ " free immediately, not defer via queue_free()"
|
|
) % [baseline_count, after_count]
|
|
).is_equal(baseline_count)
|
|
|
|
|
|
## PR #192 cold-start round 2: the children-count fix above is necessary but
|
|
## not sufficient — the coordinator's live scene dump showed the CONTAINER
|
|
## itself measured 260x2343px even after that fix landed. Root cause turned
|
|
## out to be BROADER than "only after stacking": this panel is manually
|
|
## positioned under AtlasWindowViewer (not inside a parent Container), so
|
|
## `size` NEVER tracks a shrinking `get_minimum_size()` on its own at
|
|
## all — confirmed directly (instrumented and reverted) that even a
|
|
## completely FRESH, never-refreshed-twice legend shows `size` frozen at
|
|
## whatever it happened to be on its very first measurement, while
|
|
## get_minimum_size() reports the correct value the whole time. The
|
|
## regression here therefore compares `size.y` against the RELIABLE ground
|
|
## truth (`get_minimum_size().y`, confirmed correct in every trace) rather
|
|
## than an earlier `size.y` snapshot — comparing size-to-size would pass
|
|
## trivially if BOTH numbers were equally stuck at the same stale value,
|
|
## which is exactly what silently happened during earlier drafts of this
|
|
## test. Reproduces the stacking shape (N same-frame refresh() calls) for
|
|
## realism, matching the coordinator's own trigger — the coordinator's
|
|
## acceptance bar: "after N refreshes the panel rect height must be within
|
|
## one legend's height" (of the CORRECT single-legend height, i.e. the
|
|
## settled minimum size).
|
|
func test_legend_panel_shrinks_back_after_a_stacking_window() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
|
|
# Reproduce the stacking window directly (same-frame repeat refresh()
|
|
# calls, matching the pre-fix trigger shape) — this drives the panel's
|
|
# minimum size up the same way N repeat enter_orbital() calls did live.
|
|
for _i in range(10):
|
|
v._legend_panel.refresh()
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
|
|
# A further, ordinary refresh() (the kind every real rung change already
|
|
# triggers) must leave the panel within one legend's height.
|
|
v._legend_panel.refresh()
|
|
# reset_to_content_size() is deferred (see its own doc — get_minimum_size()
|
|
# is momentarily wrong for RichTextLabel.fit_content children until the
|
|
# panel's real width has been laid out once) — a real frame must elapse
|
|
# for the deferred reset_size() call to actually run.
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
|
|
var one_legend_height: float = v._legend_panel.get_minimum_size().y
|
|
assert_float(one_legend_height).override_failure_message(
|
|
"sanity: a single settled legend must have a real, non-zero measured minimum height"
|
|
).is_greater(0.0)
|
|
assert_float(v._legend_panel.size.y).override_failure_message(
|
|
(
|
|
"after a stacking window, the legend panel's rect height (%.1f) must"
|
|
+ " shrink back to within one legend's height (%.1f, the panel's own"
|
|
+ " correctly-settled get_minimum_size()) — reset_to_content_size()"
|
|
+ " must actually collapse the Control back down, not just hold onto"
|
|
+ " its previously-grown size"
|
|
) % [v._legend_panel.size.y, one_legend_height]
|
|
).is_less_equal(one_legend_height + 1.0) # +1.0: float rounding slack
|
|
|
|
|
|
# =============================================================================
|
|
# BUG 3 (round 2) — "DERIVING TERRAIN…" label: the subtle per-tile
|
|
# COLOR_BORDER_FADE wash alone was invisible in a live cold capture. The
|
|
# viewer draws an unmistakable centered label while ZERO tiles have arrived,
|
|
# dropping it the instant even one lands.
|
|
# =============================================================================
|
|
|
|
|
|
## The viewer must show the label exactly while is_tile_mode() is true AND
|
|
## has_any_tile_arrived() is false — the coordinator's "ZERO tiles have
|
|
## arrived" trigger condition, pinned directly against real tile-set state
|
|
## (not a mock) via a real enter_orbital() on a tiling body. Also exercises
|
|
## _draw()'s ACTUAL dispatch to _draw_deriving_terrain_label() through a
|
|
## real frame (queue_redraw() + await process_frame, matching the
|
|
## _CountingOverlay spy pattern from the BUG 1 self-heal tests above) —
|
|
## proving the draw call itself is reachable and doesn't error, not just
|
|
## that the underlying predicate is correct.
|
|
func test_deriving_terrain_label_condition_true_before_any_tile_arrives() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling
|
|
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
|
assert_bool(v.is_tile_mode()).is_true()
|
|
assert_bool(v._tile_set.has_any_tile_arrived()).override_failure_message(
|
|
"sanity: entry must leave every tile unarrived before any response arrives"
|
|
).is_false()
|
|
v.queue_redraw()
|
|
await get_tree().process_frame
|
|
|
|
|
|
## The instant even ONE tile lands, the label's own gate condition must flip
|
|
## off — per-tile washes alone are the right treatment once real content is
|
|
## visibly filling in (coordinator: "dropping to per-tile washes once the
|
|
## first tile lands").
|
|
func test_deriving_terrain_label_condition_false_after_one_tile_arrives() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
var radius_km := 6238.4 # GJ380c (Lendel)
|
|
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
|
var tile_set = v.get_tile_set()
|
|
var first_tile: Dictionary = tile_set.get_tiles()[0]
|
|
var window: Dictionary = _mock_window(first_tile["center"])
|
|
window["granularity_v2"] = "Region"
|
|
window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL
|
|
window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
|
|
assert_bool(tile_set.has_any_tile_arrived()).override_failure_message(
|
|
"the label's own gate condition (NOT has_any_tile_arrived()) must flip"
|
|
+ " false the instant a single tile lands, dropping the label"
|
|
).is_true()
|
|
|
|
|
|
## Single-window mode (District/Quarter/small-body Region, not tile mode)
|
|
## never shows this label at all — it's a mosaic-specific cue for the
|
|
## "whole orbital rest state is still deriving" case, not every wait state
|
|
## (the single-window path already has its own COLOR_BORDER_FADE treatment,
|
|
## unchanged by this round).
|
|
func test_deriving_terrain_label_never_applies_outside_tile_mode() -> void:
|
|
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
|
add_child(v)
|
|
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
|
assert_bool(v.is_tile_mode()).override_failure_message(
|
|
"sanity: a District-rung enter() must never be tile mode"
|
|
).is_false()
|
|
|
|
|
|
## centered_label_baseline() pure geometry: the X component is the
|
|
## VIEWPORT-CENTERED text block's left-anchor-adjusted X (viewport center
|
|
## minus half the text width — draw_string() itself does the final
|
|
## horizontal centering from there via HORIZONTAL_ALIGNMENT_CENTER, this
|
|
## only sets up where that alignment measures from); the Y component sits
|
|
## at viewport-center (a draw_string() baseline is the text's OWN vertical
|
|
## center here, by construction: center.y - text.y/2 + text.y/2 == center.y).
|
|
func test_centered_label_baseline_centers_a_symmetric_case() -> void:
|
|
var viewport_size := Vector2(1000.0, 800.0)
|
|
var text_size := Vector2(200.0, 40.0)
|
|
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(viewport_size, text_size)
|
|
assert_that(baseline).is_equal(Vector2(400.0, 400.0))
|
|
|
|
|
|
## A zero-size viewport (never laid out yet) must not crash — degenerate
|
|
## input, not a real scenario, but the function must stay total.
|
|
func test_centered_label_baseline_zero_viewport_does_not_crash() -> void:
|
|
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(
|
|
Vector2.ZERO, Vector2(100.0, 20.0)
|
|
)
|
|
assert_that(baseline).is_equal(Vector2(-50.0, 0.0))
|