feat(client): atlas roads/settlements overlays + legend + server-API data delivery (T-960, T-949)
T-960: gen_l2_roads (MaintenanceAuthority-colored polylines, rail styling, junction markers) + gen_l3_settlements (size-scaled markers, capital shape, name labels) overlays — cities render on generated bodies for the first time; left-side generation legend panel (D-226 item 3, data-driven per-overlay spec, implant component library); protocol.gd decodes road_graph/settlements + the two new response types. T-949: system_index/atlas_app/overview_screen migrated off the direct star_map_data.json read to StarMapRequest over the bridge (loading state + replay-on-connect, no silent file fallback); atlas_viewer _load_markers requests CityNamesResponse for non-Sol bodies; Sol keeps the legacy authored markers.json geometry read (D-236/T-1073, load-bearing guard). Lead fix: _send_star_map_request now carries the same guard as request_star_map — the autoload's _star_map_wanted leaked across gdUnit suites and the unguarded replay-on-CONNECTED crashed 8 pre-existing flow tests on a Nil bridge; reset_test_state clears the flag. Fixtures regenerated via gen_fixtures (road/settlement samples). Full suite 2946/2946. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
ƒ§body_id¥ghost¦status¨NotFound¦layer1À
|
||||
†§body_id¥ghost¦status¨NotFound¦layer1Àdistrict_gridÀªroad_graphÀ«settlementsÀ
|
||||
@@ -1 +1 @@
|
||||
�body_id二J1c存tatus判ending奸ayer1�
|
||||
�body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements�
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,178 @@
|
||||
## 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})
|
||||
|
||||
v._on_city_names_received(
|
||||
{"body_id": NON_SOL_BODY_ID, "status": {"Error": "db unavailable"}, "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()
|
||||
@@ -0,0 +1,236 @@
|
||||
## T-949 tests: Atlas data delivery migration — StarMapRequest/StarMapResponse
|
||||
## and CityNamesRequest/CityNamesResponse (protocol.gd encode/decode,
|
||||
## decode_inbound's frame classification, and SimBridge signal routing).
|
||||
##
|
||||
## Wire shapes pinned to dudley-atlas-server's contract (2026-07-14):
|
||||
## AtlasLayerResponse gains road_graph/settlements (Option, "None until ready"
|
||||
## like district_grid); StarMapResponse is {status, data} (data = a verbatim
|
||||
## MessagePack re-encoding of star_map_data.json, unwrapped by
|
||||
## star_map_response_from_raw so callers see {"nodes": [...]} directly);
|
||||
## CityNamesResponse is {body_id, status, cities: [{city_id, name, is_capital}]}
|
||||
## with a SolExcluded status as the server-side Sol backstop (D-236).
|
||||
class_name TestAtlasDataDelivery
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Encode/decode round-trips
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_encode_star_map_request_carries_discriminator() -> void:
|
||||
var bytes := Protocol.encode_star_map_request()
|
||||
assert_int(bytes.size()).is_greater(0)
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status == null).is_true()
|
||||
assert_that(decoded.value.get("star_map")).is_equal(true)
|
||||
|
||||
|
||||
func test_star_map_response_from_raw_unwraps_data_to_nodes() -> void:
|
||||
var nodes := [
|
||||
{"system_id": "GJ 71", "proper_name": "Tau Ceti"},
|
||||
{"system_id": "GJ 0", "proper_name": "Sol"},
|
||||
]
|
||||
var raw := {"status": "Ready", "data": {"_meta": {"system_count": 2}, "nodes": nodes}}
|
||||
var response: Variant = Protocol.star_map_response_from_raw(raw)
|
||||
assert_that(response).is_not_null()
|
||||
assert_str((response as Dictionary).get("status")).is_equal("Ready")
|
||||
assert_that((response as Dictionary).get("nodes")).is_equal(nodes)
|
||||
|
||||
|
||||
func test_star_map_response_from_raw_error_status_yields_no_nodes() -> void:
|
||||
var raw := {"status": {"Error": "db unavailable"}, "data": null}
|
||||
var response: Variant = Protocol.star_map_response_from_raw(raw)
|
||||
assert_that(response).is_not_null()
|
||||
assert_str((response as Dictionary).get("status")).is_equal("Error")
|
||||
assert_str((response as Dictionary).get("error")).is_equal("db unavailable")
|
||||
assert_that((response as Dictionary).get("nodes")).is_equal([])
|
||||
|
||||
|
||||
func test_star_map_response_from_raw_rejects_shape_without_status() -> void:
|
||||
assert_that(Protocol.star_map_response_from_raw({"data": {}})).is_null()
|
||||
assert_that(Protocol.star_map_response_from_raw("not a dict")).is_null()
|
||||
|
||||
|
||||
func test_decode_star_map_response_bytes_round_trip() -> void:
|
||||
var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71"}]}}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var response: Variant = Protocol.decode_star_map_response(encoded.value)
|
||||
assert_that(response).is_not_null()
|
||||
assert_that((response as Dictionary).get("nodes")).is_equal(raw["data"]["nodes"])
|
||||
|
||||
|
||||
func test_encode_city_names_request_carries_discriminator_and_body_id() -> void:
|
||||
var bytes := Protocol.encode_city_names_request("GJ903b")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status == null).is_true()
|
||||
assert_that(decoded.value.get("city_names")).is_equal(true)
|
||||
assert_that(decoded.value.get("body_id")).is_equal("GJ903b")
|
||||
|
||||
|
||||
func test_city_names_response_from_raw_round_trips_ready() -> void:
|
||||
var cities := [{"city_id": 1, "name": "Ridgeback", "is_capital": false}]
|
||||
var raw := {"body_id": "GJ903b", "status": "Ready", "cities": cities}
|
||||
var response: Variant = Protocol.city_names_response_from_raw(raw)
|
||||
assert_that(response).is_not_null()
|
||||
assert_str((response as Dictionary).get("body_id")).is_equal("GJ903b")
|
||||
assert_str((response as Dictionary).get("status")).is_equal("Ready")
|
||||
assert_that((response as Dictionary).get("cities")).is_equal(cities)
|
||||
|
||||
|
||||
func test_city_names_response_from_raw_sol_excluded() -> void:
|
||||
var raw := {"body_id": "GJ0d", "status": "SolExcluded", "cities": []}
|
||||
var response: Variant = Protocol.city_names_response_from_raw(raw)
|
||||
assert_that(response).is_not_null()
|
||||
assert_str((response as Dictionary).get("status")).is_equal("SolExcluded")
|
||||
|
||||
|
||||
func test_city_names_response_from_raw_requires_body_id_and_status() -> void:
|
||||
assert_that(Protocol.city_names_response_from_raw({"body_id": "GJ903b"})).is_null()
|
||||
assert_that(Protocol.city_names_response_from_raw({"status": "Ready"})).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# decode_inbound classification — snapshot / atlas / starmap / citynames
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_decode_inbound_classifies_snapshot() -> void:
|
||||
var raw := {"tick": 1, "entities": []}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
|
||||
assert_str(inbound.kind).is_equal("snapshot")
|
||||
|
||||
|
||||
func test_decode_inbound_classifies_atlas() -> void:
|
||||
var raw := {"body_id": "GJ1c", "status": "Ready", "layer1": null, "district_grid": null}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
|
||||
assert_str(inbound.kind).is_equal("atlas")
|
||||
|
||||
|
||||
func test_decode_inbound_classifies_starmap() -> void:
|
||||
# StarMapResponse is the only kind with "data" and no "body_id".
|
||||
var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71"}]}}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
|
||||
assert_str(inbound.kind).is_equal("starmap")
|
||||
assert_that((inbound.value as Dictionary).get("nodes")).is_equal(raw["data"]["nodes"])
|
||||
|
||||
|
||||
func test_decode_inbound_classifies_citynames() -> void:
|
||||
# CityNamesResponse is the only kind with "cities" — must classify as
|
||||
# citynames, NOT atlas, even though both carry status + body_id.
|
||||
var raw := {"body_id": "GJ903b", "status": "Ready", "cities": [{"city_id": 1, "name": "Ridgeback"}]}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
|
||||
assert_str(inbound.kind).is_equal("citynames")
|
||||
assert_that((inbound.value as Dictionary).get("body_id")).is_equal("GJ903b")
|
||||
|
||||
|
||||
func test_atlas_response_road_graph_and_settlements_passthrough() -> void:
|
||||
var road_graph := {
|
||||
"nodes": [{"position": [1, 2], "kind": "Settlement", "city_id": 5}],
|
||||
"edges":
|
||||
[
|
||||
{
|
||||
"from": 0,
|
||||
"to": 1,
|
||||
"path": [[1, 2], [3, 4]],
|
||||
"maintenance": "Trade",
|
||||
"is_rail": false,
|
||||
"named_route_id": null,
|
||||
}
|
||||
],
|
||||
}
|
||||
var settlements := {
|
||||
"settlements":
|
||||
[
|
||||
{
|
||||
"city_id": 5,
|
||||
"name": "Ridgeback",
|
||||
"position": [1, 2],
|
||||
"size_class": "Minor",
|
||||
"is_capital": false,
|
||||
"is_port": false,
|
||||
}
|
||||
]
|
||||
}
|
||||
var raw := {
|
||||
"body_id": "GJ1c",
|
||||
"status": "Ready",
|
||||
"road_graph": road_graph,
|
||||
"settlements": settlements,
|
||||
}
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that((decoded as Dictionary).get("road_graph")).is_equal(road_graph)
|
||||
assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements)
|
||||
|
||||
|
||||
func test_atlas_response_road_graph_and_settlements_default_null() -> void:
|
||||
# A Ready response before road_graph/settlements land on a given server
|
||||
# build must decode cleanly with both fields absent, not error.
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw({"body_id": "GJ1c", "status": "Ready"})
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that((decoded as Dictionary).get("road_graph")).is_null()
|
||||
assert_that((decoded as Dictionary).get("settlements")).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SimBridge routing — receive_bytes emits the right signal with the right value
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_sim_bridge_routes_star_map_response_to_signal() -> void:
|
||||
var received: Array = []
|
||||
var handler := func(response: Dictionary) -> void: received.append(response)
|
||||
SimBridge.star_map_received.connect(handler)
|
||||
|
||||
var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71", "proper_name": "Tau Ceti"}]}}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
SimBridge.receive_bytes(encoded.value)
|
||||
|
||||
SimBridge.star_map_received.disconnect(handler)
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_that(received[0].get("nodes")).is_equal(raw["data"]["nodes"])
|
||||
|
||||
|
||||
func test_sim_bridge_routes_city_names_response_to_signal() -> void:
|
||||
var received: Array = []
|
||||
var handler := func(response: Dictionary) -> void: received.append(response)
|
||||
SimBridge.city_names_received.connect(handler)
|
||||
|
||||
var raw := {
|
||||
"body_id": "GJ903b",
|
||||
"status": "Ready",
|
||||
"cities": [{"city_id": 1, "name": "Ridgeback", "is_capital": false}],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
SimBridge.receive_bytes(encoded.value)
|
||||
|
||||
SimBridge.city_names_received.disconnect(handler)
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_that(received[0].get("body_id")).is_equal("GJ903b")
|
||||
|
||||
|
||||
func test_sim_bridge_still_routes_snapshots_after_starmap_additions() -> void:
|
||||
# Regression guard: adding the starmap/citynames branches must not steal
|
||||
# ordinary snapshot frames (the 20 Hz hot path).
|
||||
var raw := {"tick": 5, "entities": []}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
SimBridge.receive_bytes(encoded.value)
|
||||
assert_that(SimBridge._last_snapshot).is_not_null()
|
||||
assert_int(SimBridge._last_snapshot.get("tick")).is_equal(5)
|
||||
|
||||
|
||||
func test_request_star_map_is_noop_in_test_mode() -> void:
|
||||
# SimBridge defaults to test_mode = true (SR_LIVE unset) — request_star_map
|
||||
# must not error even with no live bridge/server.
|
||||
SimBridge.request_star_map()
|
||||
|
||||
|
||||
func test_request_city_names_is_noop_in_test_mode() -> void:
|
||||
SimBridge.request_city_names("GJ903b")
|
||||
@@ -4,6 +4,11 @@
|
||||
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")
|
||||
|
||||
|
||||
func test_generation_overlays_registered() -> void:
|
||||
var ids: Array = []
|
||||
@@ -53,3 +58,190 @@ func test_implant_pending_start_stop() -> void:
|
||||
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)
|
||||
|
||||
|
||||
## Tier-2 replay: a REAL server-generated msgpack blob (server/tests/gen_fixtures.rs
|
||||
## generate_atlas_layer_response_fixtures, regenerated 2026-07-14 with T-960's
|
||||
## road_graph/settlements 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()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
## T-949 tests: SystemIndex — the cached StarMapResponse reader that replaces
|
||||
## the direct star_map_data.json FileAccess read (atlas_app.gd's Reach screen,
|
||||
## economics OverviewScreen).
|
||||
##
|
||||
## Fixtures use {"status": "Ready", "nodes": [...]} — the shape
|
||||
## protocol.gd's star_map_response_from_raw hands to ingest() AFTER unwrapping
|
||||
## the real StarMapResponse's status/data envelope (dudley-atlas-server
|
||||
## contract, 2026-07-14); SystemIndex itself never sees "data" directly.
|
||||
##
|
||||
## SystemIndex's cache is process-global (static var — there is no per-call
|
||||
## instance), so these tests are written to be order-independent: every test
|
||||
## calls ingest() with its own "Ready" fixture first, which fully OVERWRITES
|
||||
## the cache rather than appending, so prior test pollution can't leak into
|
||||
## an assertion (see system_index.gd's ingest()).
|
||||
class_name TestSystemIndex
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func test_ingest_sorts_by_proper_name() -> void:
|
||||
SystemIndex.ingest(
|
||||
{
|
||||
"status": "Ready",
|
||||
"nodes":
|
||||
[
|
||||
{"system_id": "GJ 71", "proper_name": "Tau Ceti"},
|
||||
{"system_id": "GJ 0", "proper_name": "Sol"},
|
||||
{"system_id": "GJ 903", "proper_name": "Aldrin"},
|
||||
]
|
||||
}
|
||||
)
|
||||
var sorted: Array = SystemIndex.get_sorted_systems()
|
||||
assert_int(sorted.size()).is_equal(3)
|
||||
assert_str(sorted[0].get("proper_name")).is_equal("Aldrin")
|
||||
assert_str(sorted[1].get("proper_name")).is_equal("Sol")
|
||||
assert_str(sorted[2].get("proper_name")).is_equal("Tau Ceti")
|
||||
|
||||
|
||||
func test_ingest_falls_back_to_system_id_when_proper_name_missing() -> void:
|
||||
SystemIndex.ingest(
|
||||
{
|
||||
"status": "Ready",
|
||||
"nodes":
|
||||
[
|
||||
{"system_id": "GJ 999"},
|
||||
{"system_id": "GJ 001"},
|
||||
]
|
||||
}
|
||||
)
|
||||
var sorted: Array = SystemIndex.get_sorted_systems()
|
||||
assert_int(sorted.size()).is_equal(2)
|
||||
assert_str(sorted[0].get("system_id")).is_equal("GJ 001")
|
||||
assert_str(sorted[1].get("system_id")).is_equal("GJ 999")
|
||||
|
||||
|
||||
func test_ingest_drops_nodes_with_empty_system_id() -> void:
|
||||
SystemIndex.ingest(
|
||||
{
|
||||
"status": "Ready",
|
||||
"nodes":
|
||||
[
|
||||
{"system_id": "GJ 71", "proper_name": "Tau Ceti"},
|
||||
{"proper_name": "No id"},
|
||||
{"system_id": "", "proper_name": "Empty id"},
|
||||
]
|
||||
}
|
||||
)
|
||||
var sorted: Array = SystemIndex.get_sorted_systems()
|
||||
assert_int(sorted.size()).is_equal(1)
|
||||
assert_str(sorted[0].get("system_id")).is_equal("GJ 71")
|
||||
|
||||
|
||||
func test_ingest_marks_loaded() -> void:
|
||||
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 71"}]})
|
||||
assert_bool(SystemIndex.is_loaded()).is_true()
|
||||
|
||||
|
||||
func test_ingest_overwrites_not_appends() -> void:
|
||||
SystemIndex.ingest(
|
||||
{"status": "Ready", "nodes": [{"system_id": "GJ 1"}, {"system_id": "GJ 2"}]}
|
||||
)
|
||||
assert_int(SystemIndex.get_sorted_systems().size()).is_equal(2)
|
||||
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 3"}]})
|
||||
# A second StarMapResponse (e.g. a reconnect) replaces the cache wholesale
|
||||
# rather than accumulating duplicates.
|
||||
assert_int(SystemIndex.get_sorted_systems().size()).is_equal(1)
|
||||
assert_str(SystemIndex.get_sorted_systems()[0].get("system_id")).is_equal("GJ 3")
|
||||
|
||||
|
||||
func test_ingest_ignores_non_ready_status() -> void:
|
||||
# Seed a known-good cache first...
|
||||
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 1"}]})
|
||||
# ...an Error response (protocol.gd already reduces it to nodes=[]) must
|
||||
# NOT wipe the cache down to empty — the last good data survives.
|
||||
SystemIndex.ingest({"status": "Error", "error": "db unavailable", "nodes": []})
|
||||
assert_int(SystemIndex.get_sorted_systems().size()).override_failure_message(
|
||||
"a non-Ready StarMapResponse must not overwrite the cache"
|
||||
).is_equal(1)
|
||||
assert_str(SystemIndex.get_sorted_systems()[0].get("system_id")).is_equal("GJ 1")
|
||||
|
||||
|
||||
func test_request_refresh_retries_after_a_failed_ingest() -> void:
|
||||
# request_refresh() is a no-op in test mode either way (SimBridge has no
|
||||
# live connection), so this only proves the _requested/_loaded bookkeeping
|
||||
# doesn't get stuck: a failed ingest must clear _requested so a later
|
||||
# request_refresh() is willing to ask again (not just silently no-op
|
||||
# forever because a prior request was already "in flight").
|
||||
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 1"}]})
|
||||
assert_bool(SystemIndex.is_loaded()).is_true()
|
||||
SystemIndex.request_refresh() # no-op — already loaded
|
||||
|
||||
|
||||
func test_request_refresh_does_not_crash_when_already_loaded() -> void:
|
||||
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 71"}]})
|
||||
# Idempotent no-op once loaded — must not error (SimBridge is in
|
||||
# test_mode, so request_star_map() would no-op regardless).
|
||||
SystemIndex.request_refresh()
|
||||
assert_bool(SystemIndex.is_loaded()).is_true()
|
||||
Reference in New Issue
Block a user