The true cause of the black first launch, server-confirmed after four disproven theories: a stone-cold body answers the FIRST window request with a whole-response status 'Pending' (only the whole-body cache-hit branch sets Ready), and on_response()'s very first check — status != Ready -> return — swallowed it before the retry machinery could run. retries stayed 0 forever; the DERIVING state never resolved. Second connections worked by luck (the first request warms the whole-body cache, so they read Ready and take the healthy path). Both tile fan-out AND single-window first-descents were affected — one shared function, one fix: branch on the outer status first (the atlas_generation_proxy reference shape): Pending -> retry, Ready -> existing null-window retry, NotFound/Error -> give up immediately (the principled give-up policy, replacing the elapsed-retries ceiling). Hardening in the same round: deterministic exponential backoff (0.5s doubling, 4s cap) + per-tile stagger (0.1s * index — six tiles retry at 0.5/0.6/0.7/0.8/0.9/1.0s, strictly-increasing asserted, not jittered); MAX_RETRIES 20->30 (~110s horizon under backoff). Regressions are wire-accurate by construction: whole-response Pending (body_id only, no center/n/granularity — verified against the server's own response construction) delivered through the REAL fan-out (tile_set._on_atlas_layers_received, never tile.on_response directly), mirrored at single-window level. Two first-draft tests that passed with the bug reverted were caught and strengthened before reporting; every fix and hardening piece revert-verified independently (7+1 failing tests without them).
435 lines
19 KiB
GDScript
435 lines
19 KiB
GDScript
## T-1153, live round 3 (Jeroen's ruling, design doc §4): tests for
|
|
## AtlasWindowTileSet — the orbital rest-state multi-window mosaic
|
|
## orchestration. Same hand-built-response-dict conventions as
|
|
## test_atlas_window_request.gd/test_atlas_zoom_ladder.gd; this file is
|
|
## about the ORCHESTRATION (N tiles, progressive per-tile arrival,
|
|
## teardown), not the tile-grid MATH (already covered directly against
|
|
## AtlasWindowGeometry.compute_tile_grid() in test_atlas_window_geometry.gd).
|
|
class_name TestAtlasWindowTileSet
|
|
extends GdUnitTestSuite
|
|
|
|
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
|
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
|
|
|
|
|
static func _mock_window(center: Vector2i, n: int) -> Dictionary:
|
|
return {
|
|
"center": [center.x, center.y],
|
|
"n": n,
|
|
"granularity_v2": "Region",
|
|
"morphology": PackedByteArray([1, 2, 3, 4]),
|
|
"elev_q": PackedByteArray([10, 20, 30, 40]),
|
|
"temp_dc": [0, 0, 0, 0],
|
|
"moisture_q": PackedByteArray([0, 0, 0, 0]),
|
|
"vegetation": PackedByteArray([0, 0, 0, 0]),
|
|
"glaciation": PackedByteArray([0, 0, 0, 0]),
|
|
}
|
|
|
|
|
|
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
|
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
|
|
|
|
|
## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's
|
|
## first-ever response — server/src/atlas/layer_proxy.rs's
|
|
## get_or_generate()/serve_district_window() on a whole-body cache MISS
|
|
## builds `AtlasLayerResponse { status: Pending, district_window: None, ... }`
|
|
## (confirmed directly against that source). `body_id` is the ONLY
|
|
## identifying field — no center/n/granularity anywhere, matching the real
|
|
## wire's total lack of per-request attribution on this specific shape.
|
|
static func _pending_response(body_id: String) -> Dictionary:
|
|
return {"body_id": body_id, "status": "Pending", "district_window": null}
|
|
|
|
|
|
static func _not_found_response(body_id: String) -> Dictionary:
|
|
return {"body_id": body_id, "status": "NotFound", "district_window": null}
|
|
|
|
|
|
## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded
|
|
## `AtlasLayerStatus::Error(String)` variant's status STRING is always just
|
|
## "Error" (the message rides in a separate `error` field, not appended to
|
|
## the status string), confirmed against that decoder directly.
|
|
static func _error_response(body_id: String, message: String = "boom") -> Dictionary:
|
|
return {"body_id": body_id, "status": "Error", "error": message, "district_window": null}
|
|
|
|
|
|
func _make_tile_set() -> Variant:
|
|
var owner_stub := RefCounted.new()
|
|
var ts = auto_free(AtlasWindowTileSet.new(owner_stub))
|
|
add_child(ts)
|
|
return ts
|
|
|
|
|
|
# =============================================================================
|
|
# enter() — tile grid computation + one request per tile
|
|
# =============================================================================
|
|
|
|
|
|
## enter() on a real, tiling-sized body must produce the SAME tile count
|
|
## compute_tile_grid() would — 6 for GJ380c/Lendel, the coordinator's own
|
|
## live-round number.
|
|
func test_enter_produces_the_expected_tile_count_for_lendel() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
assert_int(ts.get_tile_count()).is_equal(6)
|
|
assert_bool(ts.is_multi_tile()).is_true()
|
|
|
|
|
|
## PR #192 cold-start round 3 hardening: each tile's own AtlasWindowRequest
|
|
## must get a DISTINCT, index-matching `_stagger_index` — the anti-storm
|
|
## property (deterministic retry-delay stagger, see
|
|
## AtlasWindowRequest._retry_delay_for()'s own doc) depends entirely on this
|
|
## wiring; without it every tile silently staggers at index 0 and retries
|
|
## in lockstep again, exactly the storm risk this hardening exists to close.
|
|
func test_enter_wires_a_distinct_stagger_index_per_tile() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
for i in range(ts._tiles.size()):
|
|
var req = ts._tiles[i]["request"]
|
|
assert_int(req._stagger_index).override_failure_message(
|
|
"tile %d's request must be wired with _stagger_index=%d, matching"
|
|
+ " its own position in the tile set" % [i, i]
|
|
).is_equal(i)
|
|
|
|
|
|
## A tiny (non-tiling) body produces exactly ONE tile — the degenerate case
|
|
## compute_tile_grid() itself already covers; this confirms the ORCHESTRATION
|
|
## (not just the grid math) handles it without crashing or requesting zero
|
|
## tiles.
|
|
func test_enter_tiny_body_produces_one_tile() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("TinyBody", 50.0)
|
|
assert_int(ts.get_tile_count()).is_equal(1)
|
|
assert_bool(ts.is_multi_tile()).is_false()
|
|
|
|
|
|
## Every tile must start with a null window (nothing has arrived yet) and
|
|
## the tile set must not report "fully arrived" before any response lands.
|
|
func test_enter_all_tiles_start_unarrived() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
for tile: Dictionary in ts.get_tiles():
|
|
assert_that(tile["window"]).is_null()
|
|
assert_bool(ts.is_fully_arrived()).is_false()
|
|
|
|
|
|
## An empty tile set (never entered) must not report "fully arrived" either
|
|
## — an empty AND-over-nothing must not vacuously read true.
|
|
func test_empty_tile_set_is_not_fully_arrived() -> void:
|
|
var ts = _make_tile_set()
|
|
assert_bool(ts.is_fully_arrived()).is_false()
|
|
|
|
|
|
# =============================================================================
|
|
# Progressive per-tile arrival (design doc §4: "with visible refinement as
|
|
# tiles complete") — each tile's response is independent of every other's.
|
|
# =============================================================================
|
|
|
|
|
|
## Delivering ONE tile's response must populate ONLY that tile's window,
|
|
## leaving every other tile still null — the direct "progressive, not
|
|
## block-on-all" regression.
|
|
func test_one_tile_arriving_does_not_affect_the_others() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var tiles: Array = ts.get_tiles()
|
|
var first_center: Vector2i = tiles[0]["center"]
|
|
var window: Dictionary = _mock_window(first_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
|
|
var updated_tiles: Array = ts.get_tiles()
|
|
assert_that(updated_tiles[0]["window"]).override_failure_message(
|
|
"the tile whose response arrived must have its window populated"
|
|
).is_equal(window)
|
|
for i in range(1, updated_tiles.size()):
|
|
assert_that(updated_tiles[i]["window"]).override_failure_message(
|
|
"tile %d must still be unarrived — only tile 0's response was delivered" % i
|
|
).is_null()
|
|
|
|
|
|
## tile_ready must fire with the INDEX of the tile that actually arrived —
|
|
## the viewer/overlay needs this to know WHICH tile to redraw, not just
|
|
## "something changed".
|
|
func test_tile_ready_signal_fires_with_the_correct_index() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var received_indices: Array = []
|
|
ts.tile_ready.connect(func(index: int) -> void: received_indices.append(index))
|
|
|
|
var tiles: Array = ts.get_tiles()
|
|
var second_center: Vector2i = tiles[1]["center"]
|
|
var window: Dictionary = _mock_window(second_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
|
|
assert_int(received_indices.size()).is_equal(1)
|
|
assert_int(received_indices[0]).is_equal(1)
|
|
|
|
|
|
## Delivering EVERY tile's response must flip is_fully_arrived() to true —
|
|
## the mosaic-complete signal the viewer/legend chrome can use.
|
|
func test_all_tiles_arriving_flips_fully_arrived() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var tiles: Array = ts.get_tiles()
|
|
for tile: Dictionary in tiles:
|
|
var window: Dictionary = _mock_window(
|
|
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
|
|
assert_bool(ts.is_fully_arrived()).override_failure_message(
|
|
"once every tile's response has arrived, the tile set must report fully arrived"
|
|
).is_true()
|
|
|
|
|
|
# =============================================================================
|
|
# PR #192 cold-start round 3 — THE launch-shape regression: a cold body's
|
|
# FIRST-EVER response is a wire-accurate whole-response Pending (status
|
|
# "Pending", district_window null, body_id only — no center/n/granularity
|
|
# anywhere, confirmed against server/src/atlas/layer_proxy.rs directly).
|
|
# Driven through the REAL fan-out (SimBridge.atlas_layers_received.emit(),
|
|
# reaching every tile via tile_set._on_atlas_layers_received), not a direct
|
|
# tile.on_response() call — the coordinator's own regression-discipline ask,
|
|
# since a direct per-tile call is implicit attribution a real wire fan-out
|
|
# doesn't have. Before the status-gate fix, on_response()'s FIRST line
|
|
# (`status != "Ready" -> return`) dropped this response for every tile
|
|
# before ever reaching the retry-scheduling code — retries stayed at 0
|
|
# forever, matching the coordinator's own live cold-server capture exactly.
|
|
# =============================================================================
|
|
|
|
|
|
## The exact bug: a whole-response Pending on cold entry must increment
|
|
## every tile's retry counter and schedule a re-poll — not be silently
|
|
## dropped. Fails hard against the pre-fix code (retries stay 0 forever).
|
|
func test_cold_entry_whole_response_pending_increments_every_tiles_retry_count() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
|
|
SimBridge.atlas_layers_received.emit(_pending_response("GJ380c"))
|
|
|
|
for tile_dict: Dictionary in ts._tiles:
|
|
var req = tile_dict["request"]
|
|
assert_bool(req.is_pending()).override_failure_message(
|
|
"every tile must still be pending after a whole-response Pending"
|
|
).is_true()
|
|
assert_int(req._retries).override_failure_message(
|
|
"a whole-response Pending must increment the retry counter — the"
|
|
+ " exact bug: the OLD status-gate silently dropped this before"
|
|
+ " ever reaching the retry-scheduling code, leaving retries at 0 forever"
|
|
).is_equal(1)
|
|
|
|
|
|
## The full convergence: repeated whole-response Pendings (simulating a slow
|
|
## cold AnalyzeBody), THEN real per-tile Ready responses for the tiles' own
|
|
## RE-REQUESTS — every tile must eventually fill. No re-request means this
|
|
## hangs (waiting past the retry delay for a re-poll that never happens) or
|
|
## fails (has_pending_tiles() never flips false) — exactly the launch-shape
|
|
## gap the coordinator named: "every unit test delivered Ready immediately."
|
|
func test_cold_entry_converges_after_repeated_pending_then_real_readies() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var tiles: Array = ts.get_tiles()
|
|
|
|
# Two consecutive whole-response Pendings — a slow cold derive, not a
|
|
# single flip. Real waits so the scheduled retry timers can actually fire.
|
|
SimBridge.atlas_layers_received.emit(_pending_response("GJ380c"))
|
|
await get_tree().create_timer(0.7).timeout # past the first backoff delay
|
|
SimBridge.atlas_layers_received.emit(_pending_response("GJ380c"))
|
|
await get_tree().create_timer(0.8).timeout # past the second (staggered) delay
|
|
|
|
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
|
"sanity: still pending after 2 cold cycles"
|
|
).is_true()
|
|
for tile_dict: Dictionary in ts._tiles:
|
|
var req = tile_dict["request"]
|
|
assert_int(req._retries).override_failure_message(
|
|
"sanity: each tile's retry counter must have actually incremented"
|
|
+ " twice — otherwise the final assertion below would pass even if"
|
|
+ " the retries never happened at all (a fresh Ready was never the"
|
|
+ " broken behavior, only the retry itself was)"
|
|
).is_equal(2)
|
|
|
|
for tile: Dictionary in tiles:
|
|
var window: Dictionary = _mock_window(
|
|
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
|
|
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
|
"after the retries fire and real Readies land, every tile must have adopted its window"
|
|
).is_false()
|
|
assert_bool(ts.is_fully_arrived()).is_true()
|
|
|
|
|
|
## NotFound must give up IMMEDIATELY, not retry — a body that doesn't exist
|
|
## will never resolve by waiting (the coordinator's own "give up on error
|
|
## responses, not elapsed patience" framing — this replaces the old
|
|
## elapsed-retries-only give-up policy with a correctness-based one for the
|
|
## cases where retrying is provably pointless).
|
|
func test_cold_entry_not_found_gives_up_immediately_without_retry() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
|
|
SimBridge.atlas_layers_received.emit(_not_found_response("GJ380c"))
|
|
|
|
for tile_dict: Dictionary in ts._tiles:
|
|
var req = tile_dict["request"]
|
|
assert_bool(req.is_pending()).override_failure_message(
|
|
"NotFound must give up immediately, not stay pending waiting for a retry"
|
|
).is_false()
|
|
assert_int(req._retries).override_failure_message(
|
|
"NotFound must never increment the retry counter — retrying a"
|
|
+ " nonexistent body is provably pointless"
|
|
).is_equal(0)
|
|
|
|
|
|
## Error must give up immediately too, same reasoning as NotFound — a
|
|
## resolve/IO failure won't resolve itself by polling.
|
|
func test_cold_entry_error_gives_up_immediately_without_retry() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
|
|
SimBridge.atlas_layers_received.emit(_error_response("GJ380c"))
|
|
|
|
for tile_dict: Dictionary in ts._tiles:
|
|
var req = tile_dict["request"]
|
|
assert_bool(req.is_pending()).override_failure_message(
|
|
"Error must give up immediately, not stay pending waiting for a retry"
|
|
).is_false()
|
|
assert_int(req._retries).is_equal(0)
|
|
|
|
|
|
# =============================================================================
|
|
# has_pending_tiles() / has_any_tile_arrived() — PR #192 cold-start dossier.
|
|
# Distinct predicates (both can be true at once, mid-arrival): the viewer's
|
|
# self-healing redraw (BUG 1) polls has_pending_tiles(); the "DERIVING
|
|
# TERRAIN…" label (BUG 3) polls has_any_tile_arrived() to know when to drop.
|
|
# =============================================================================
|
|
|
|
|
|
func test_has_pending_tiles_true_immediately_after_enter() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
|
"every tile is unarrived right after enter() — has_pending_tiles() must be true"
|
|
).is_true()
|
|
|
|
|
|
func test_has_pending_tiles_false_once_every_tile_has_arrived() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
for tile: Dictionary in ts.get_tiles():
|
|
var window: Dictionary = _mock_window(
|
|
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
assert_bool(ts.has_pending_tiles()).is_false()
|
|
|
|
|
|
func test_has_pending_tiles_true_while_only_some_tiles_have_arrived() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var first_tile: Dictionary = ts.get_tiles()[0]
|
|
var window: Dictionary = _mock_window(
|
|
first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
|
"5 of 6 tiles still unarrived — has_pending_tiles() must stay true"
|
|
).is_true()
|
|
|
|
|
|
func test_has_any_tile_arrived_false_immediately_after_enter() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
assert_bool(ts.has_any_tile_arrived()).override_failure_message(
|
|
"nothing has arrived right after enter() — has_any_tile_arrived() must be false"
|
|
).is_false()
|
|
|
|
|
|
## The exact mid-arrival case both predicates must agree can coexist: one
|
|
## tile in, five still pending — the point the "DERIVING TERRAIN…" label
|
|
## must drop (has_any_tile_arrived() flips true) while the self-heal must
|
|
## keep redrawing (has_pending_tiles() stays true).
|
|
func test_has_any_tile_arrived_true_after_a_single_tile_lands() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var first_tile: Dictionary = ts.get_tiles()[0]
|
|
var window: Dictionary = _mock_window(
|
|
first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
|
assert_bool(ts.has_any_tile_arrived()).is_true()
|
|
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
|
"sanity: the other 5 tiles are still pending at the same moment"
|
|
).is_true()
|
|
|
|
|
|
func test_has_any_tile_arrived_false_for_an_empty_tile_set() -> void:
|
|
var ts = _make_tile_set()
|
|
assert_bool(ts.has_any_tile_arrived()).override_failure_message(
|
|
"an empty tile set (never entered) must not vacuously report arrival"
|
|
).is_false()
|
|
|
|
|
|
## A response for a body the tile set is NOT currently showing (a stale
|
|
## response from a body the player has since navigated away from) must not
|
|
## be adopted by any tile — the SAME body_id staleness guard every other
|
|
## AtlasWindowRequest-based path already relies on (this is inherited for
|
|
## free since each tile IS an AtlasWindowRequest, but pinned here as an
|
|
## orchestration-level regression too).
|
|
func test_response_for_a_different_body_is_ignored() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var tiles: Array = ts.get_tiles()
|
|
var window: Dictionary = _mock_window(
|
|
tiles[0]["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window))
|
|
|
|
assert_that(ts.get_tiles()[0]["window"]).is_null()
|
|
|
|
|
|
# =============================================================================
|
|
# Teardown — re-entering (a fresh body, or the same body again) must not
|
|
# leave stale tile request nodes wired up.
|
|
# =============================================================================
|
|
|
|
|
|
## Calling enter() a SECOND time (e.g. re-entering the orbital frame, or
|
|
## switching to a different body) must replace the tile set entirely — the
|
|
## OLD tiles' indices/centers must not linger.
|
|
func test_second_enter_replaces_the_tile_set() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var first_count: int = ts.get_tile_count()
|
|
assert_int(first_count).is_equal(6)
|
|
|
|
ts.enter("TinyBody", 50.0)
|
|
assert_int(ts.get_tile_count()).override_failure_message(
|
|
"a second enter() must fully replace the tile set, not append to it"
|
|
).is_equal(1)
|
|
|
|
|
|
## A response matching an OLD tile set's (body, center) — arriving AFTER a
|
|
## second enter() has already torn it down — must not be adopted (or crash):
|
|
## the old tile's AtlasWindowRequest node is queue_free()'d, and _tiles no
|
|
## longer references it, so a stale signal (if it could somehow still fire)
|
|
## has no live entry left to update.
|
|
func test_stale_response_after_second_enter_does_not_crash_or_leak() -> void:
|
|
var ts = _make_tile_set()
|
|
ts.enter("GJ380c", 6238.4)
|
|
var old_tiles: Array = ts.get_tiles()
|
|
var old_center: Vector2i = old_tiles[0]["center"]
|
|
|
|
ts.enter("GJ380c", 50.0) # same body_id, different (tiny) radius -> different tile grid
|
|
|
|
# A response shaped like it's answering the OLD tile set's first tile —
|
|
# must not crash, and must not corrupt the NEW tile set's single tile.
|
|
var stale_window: Dictionary = _mock_window(
|
|
old_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
|
)
|
|
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window))
|
|
|
|
assert_int(ts.get_tile_count()).is_equal(1)
|