Production fixes surfaced by honest test triage: - hud_groups.gd: _set_group_z crashed on freed HUD nodes — the typed loop variable errors before the is_instance_valid guard runs; prune first - fog_state.gd: _resize cleared _prev_visible (world-space keys survive resizes), so pre-resize tiles never decayed VISIBLE→EXPLORED (D-059) Test debt (T-928/929/934/935/936/937/938/939, T-864, T-973): lambda local-capture bugs rewritten with array captures (now assert exact emission counts), e2e suites updated to the current handshake + StartupMessage protocol and stream-aware reads against the live binary, fog perf test measures steady state, chime test pins the shipped 800ms catalog asset (D-067 amended separately), monologue gdUnit4 API typo, battery-warning tests follow the MetaScreen on_open lifecycle. 3 sprint2 proof tests revived (corner_reveal had passed from the wrong tile — NPC3 blocks (18,14); route corrected). Soft-skips converted to real do_skip reporting. T-1068: 7 orphan .gd.uid deleted, _format_pop/_format_radius deduped into atlas_format.gd (preload, no class_name — headless cache). Suite: 1264 cases/20 failures → 1268/0, independently re-verified (2536/2536, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
297 lines
11 KiB
GDScript
297 lines
11 KiB
GDScript
## P2 client tests: camera (5), entity alpha/color (4), UI (7).
|
|
## Validates camera tracking, entity visual state, and UI component lifecycle.
|
|
## Spec ref: sprint-9/client.md #492.
|
|
class_name TestClientP2
|
|
extends GdUnitTestSuite
|
|
|
|
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
|
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
|
|
|
var _instance: Node = null
|
|
|
|
|
|
func before_test() -> void:
|
|
SimBridge.reset_test_state()
|
|
SimBridge._last_snapshot = null
|
|
GameState.current_tick = 0
|
|
GameState.player_position = Vector2.ZERO
|
|
GameState.visible_entities = []
|
|
GameState.visible_tiles = []
|
|
GameState.visible_positions = {}
|
|
GameState.current_monologue = null
|
|
GameState.current_dialogue = null
|
|
GameState.game_time = {}
|
|
GameState.pending_recognitions = []
|
|
GameState.nearby_interactions = []
|
|
GameState.player_inventory = []
|
|
GameState.player_stance = "Walk"
|
|
GameState.dialogue_active = false
|
|
|
|
|
|
func after_test() -> void:
|
|
if _instance and is_instance_valid(_instance):
|
|
_instance.queue_free()
|
|
_instance = null
|
|
|
|
|
|
# -- Helpers -------------------------------------------------------------------
|
|
|
|
func _make_scene() -> Node:
|
|
var scene := load("res://scenes/main.tscn")
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
return _instance
|
|
|
|
|
|
func _make_entity_renderer() -> Node2D:
|
|
var renderer = Node2D.new()
|
|
renderer.set_script(EntityRendererScript)
|
|
add_child(renderer)
|
|
return renderer
|
|
|
|
|
|
func _make_fog_entities() -> Node2D:
|
|
var node = Node2D.new()
|
|
node.set_script(FogEntitiesScript)
|
|
add_child(node)
|
|
return node
|
|
|
|
|
|
# -- Camera (5) ----------------------------------------------------------------
|
|
|
|
func test_camera_zoom_default_2x() -> void:
|
|
# P2-C01: Camera zoom should be 2x as set in main.tscn.
|
|
var inst := _make_scene()
|
|
var camera: Camera2D = inst.get_node("Camera2D")
|
|
assert_that(camera.zoom).is_equal(Vector2(2, 2))
|
|
|
|
|
|
# P2-C02 (skip_test_camera_smoothing_convergence) removed (T-1068): stale since
|
|
# #117 switched to manual lerp. Superseded by test_camera_anchor.gd
|
|
# (test_camera_smoothing_stays_off_with_manual_lerp, test_camera_lerps_toward_player_movement).
|
|
|
|
|
|
func test_camera_viewport_tracks_player_position() -> void:
|
|
# P2-C03: Camera position always equals player_position * TILE_SIZE.
|
|
# No offset, no viewport clamping beyond player tracking.
|
|
var inst := _make_scene()
|
|
var camera: Camera2D = inst.get_node("Camera2D")
|
|
var expected := GameState.player_position * Constants.TILE_SIZE
|
|
assert_that(camera.global_position).override_failure_message(
|
|
"Camera must track player position exactly"
|
|
).is_equal(expected)
|
|
|
|
|
|
# P2-C04 (skip_test_camera_follows_player_after_movement) removed (T-1068): stale
|
|
# since #117 (manual lerp — exact-equality assertion invalid after one frame).
|
|
# Superseded by test_camera_anchor.gd (test_camera_tracks_player_after_process,
|
|
# test_camera_lerps_toward_player_movement).
|
|
|
|
|
|
func test_camera_no_panning_locked_to_player() -> void:
|
|
# P2-C05 (D-014): Camera is locked to player — no panning.
|
|
# Camera position equals player_position * TILE_SIZE every frame.
|
|
var inst := _make_scene()
|
|
var camera: Camera2D = inst.get_node("Camera2D")
|
|
# Process multiple frames
|
|
for i in 10:
|
|
inst._process(0.016)
|
|
var expected := GameState.player_position * Constants.TILE_SIZE
|
|
assert_that(camera.global_position).override_failure_message(
|
|
"Frame %d: camera must be locked to player" % i
|
|
).is_equal(expected)
|
|
|
|
|
|
# -- Entity alpha/color (4) ---------------------------------------------------
|
|
|
|
func test_entity_null_visibility_defaults_full_alpha() -> void:
|
|
# P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0.
|
|
var renderer := _make_entity_renderer()
|
|
var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0,
|
|
"kind": {"variant": "Npc", "data": null}}]
|
|
renderer.update_entities(entity)
|
|
var node = renderer.entity_nodes[50]
|
|
assert_that(node.modulate.a).override_failure_message(
|
|
"Null visibility should default to full alpha"
|
|
).is_equal_approx(1.0, 0.01)
|
|
renderer.queue_free()
|
|
|
|
|
|
func test_entity_peripheral_to_forward_alpha_transition() -> void:
|
|
# P2-E02: Updating entity from Peripheral to Forward changes alpha.
|
|
var renderer := _make_entity_renderer()
|
|
# First: Peripheral
|
|
var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
|
|
"kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}]
|
|
renderer.update_entities(entity_p)
|
|
var node = renderer.entity_nodes[60]
|
|
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
|
|
# Update to Forward
|
|
var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
|
|
"kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}]
|
|
renderer.update_entities(entity_f)
|
|
assert_that(node.modulate.a).override_failure_message(
|
|
"Forward visibility should set alpha to 1.0"
|
|
).is_equal_approx(1.0, 0.01)
|
|
renderer.queue_free()
|
|
|
|
|
|
func test_entity_terrain_uses_object_color() -> void:
|
|
# P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT.
|
|
var renderer := _make_entity_renderer()
|
|
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
|
|
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
|
renderer.update_entities(entity)
|
|
var node = renderer.entity_nodes[70] as Sprite2D
|
|
assert_that(node.self_modulate).override_failure_message(
|
|
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
|
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
|
renderer.queue_free()
|
|
|
|
|
|
func test_entity_player_color_regardless_of_sector() -> void:
|
|
# P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector.
|
|
GameState.player_entity_id = 80
|
|
var renderer := _make_entity_renderer()
|
|
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
|
|
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
|
renderer.update_entities(entity)
|
|
var node = renderer.entity_nodes[80] as Sprite2D
|
|
assert_that(node.self_modulate).override_failure_message(
|
|
"Player color must be constant regardless of visibility sector"
|
|
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
|
# Alpha should still be dimmed for Peripheral
|
|
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
|
|
renderer.queue_free()
|
|
|
|
|
|
# -- UI (7) --------------------------------------------------------------------
|
|
|
|
func test_monologue_display_visible_hidden() -> void:
|
|
# P2-U01 (T-864): MonologueDisplay tracks shown lines in `_visible:
|
|
# Array[Dictionary]` — empty when idle, populated after show_monologue.
|
|
var inst := _make_scene()
|
|
var mono = inst.get_node("UILayer/MonologueDisplay")
|
|
assert_int(mono._visible.size()).override_failure_message(
|
|
"Monologue must start with no visible lines"
|
|
).is_equal(0)
|
|
mono.show_monologue("Test thought.", 3.0)
|
|
assert_int(mono._visible.size()).override_failure_message(
|
|
"Monologue must have one visible line after show_monologue"
|
|
).is_equal(1)
|
|
var label: RichTextLabel = mono._visible[0].node.get_child(0)
|
|
assert_str(label.get_parsed_text()).override_failure_message(
|
|
"Visible monologue line must carry the shown text"
|
|
).is_equal("Test thought.")
|
|
|
|
|
|
func test_interaction_list_shows_nearest_verb() -> void:
|
|
# P2-U02: When nearby_interactions are present, interaction list shows verbs.
|
|
var inst := _make_scene()
|
|
var ilist = inst.get_node("InsertOverlay/InteractionList")
|
|
GameState.nearby_interactions = [{
|
|
"entity_id": 2,
|
|
"entity_type": "Npc",
|
|
"distance": 1,
|
|
"verbs": [
|
|
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
|
{"kind": "Observe", "label": "Observe", "priority": 2, "available": true},
|
|
],
|
|
}]
|
|
ilist.update_from_state()
|
|
assert_that(ilist.get_visible_verb_count()).override_failure_message(
|
|
"Interaction list should show 2 verbs"
|
|
).is_equal(2)
|
|
assert_that(ilist.is_showing()).is_true()
|
|
# Cleanup
|
|
GameState.nearby_interactions = []
|
|
|
|
|
|
func test_inventory_grid_shows_carried_items() -> void:
|
|
# P2-U03: Inventory grid becomes visible when player has items.
|
|
var inst := _make_scene()
|
|
var inv = inst.get_node("UILayer/InventoryGrid")
|
|
# Initially empty — hidden
|
|
GameState.player_inventory = []
|
|
inv.update_from_state()
|
|
assert_that(inv.visible).override_failure_message(
|
|
"Inventory should be hidden when empty"
|
|
).is_false()
|
|
# Add items
|
|
GameState.player_inventory = [
|
|
{"item_id": 1, "name": "Keycard", "slot": 0},
|
|
{"item_id": 2, "name": "Datapad", "slot": 1},
|
|
]
|
|
inv.update_from_state()
|
|
assert_that(inv.visible).override_failure_message(
|
|
"Inventory should be visible with items"
|
|
).is_true()
|
|
assert_that(inv.get_slot_count()).is_equal(2)
|
|
# Cleanup
|
|
GameState.player_inventory = []
|
|
|
|
|
|
func test_dialogue_overlay_active_on_show() -> void:
|
|
# P2-U04: Dialogue box reports active after show_dialogue.
|
|
var inst := _make_scene()
|
|
var dlg = inst.get_node("InsertOverlay/DialogueBox")
|
|
assert_that(dlg.is_dialogue_active()).override_failure_message(
|
|
"Dialogue should start inactive"
|
|
).is_false()
|
|
dlg.show_dialogue("Kael", "How's it going?", [
|
|
{"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false},
|
|
])
|
|
assert_that(dlg.is_dialogue_active()).override_failure_message(
|
|
"Dialogue should be active after show_dialogue"
|
|
).is_true()
|
|
|
|
|
|
func test_game_state_tick_rate_paused() -> void:
|
|
# P2-U05: TickRate::Paused is stored in game_time and queryable.
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"game_time": {
|
|
"day": 0,
|
|
"time_of_day": 100,
|
|
"day_phase": "Morning",
|
|
"tick_rate": "Paused",
|
|
},
|
|
})
|
|
assert_that(GameState.game_time.tick_rate).override_failure_message(
|
|
"Paused tick rate must be stored in game_time"
|
|
).is_equal("Paused")
|
|
|
|
|
|
func test_fog_entities_blob_count_matches_recognitions() -> void:
|
|
# P2-U06: FogEntities tracks one blob per pending recognition.
|
|
var fog_entities := _make_fog_entities()
|
|
var saved := GameState.pending_recognitions
|
|
GameState.pending_recognitions = [
|
|
{"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6},
|
|
{"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6},
|
|
]
|
|
fog_entities.update_from_state()
|
|
assert_that(fog_entities._entities.size()).override_failure_message(
|
|
"FogEntities should track 2 blob entities"
|
|
).is_equal(2)
|
|
assert_that(fog_entities._entities.has(101)).is_true()
|
|
assert_that(fog_entities._entities.has(102)).is_true()
|
|
GameState.pending_recognitions = saved
|
|
fog_entities.queue_free()
|
|
|
|
|
|
func test_fog_overlay_z_index_covers_world() -> void:
|
|
# P2-U07: FogOverlay renders above world content (z:900), ensuring
|
|
# occluded tiles are visually covered.
|
|
var inst := _make_scene()
|
|
var fog_overlay = inst.get_node("World/FogOverlay")
|
|
assert_that(fog_overlay.z_index).override_failure_message(
|
|
"FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG
|
|
).is_equal(Constants.Z_FOG)
|
|
# FogEntities at z:950 — above fog, below insert overlay
|
|
var fog_entities = inst.get_node("World/FogEntities")
|
|
assert_that(fog_entities.z_index).override_failure_message(
|
|
"FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES
|
|
).is_equal(Constants.Z_FOG_ENTITIES)
|