extends GdUnitTestSuite ## NEO-131: quest completion reward label HUD tests. ## NEO-132: quest_completion_reward_transition signal for economy refresh. const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") const ItemDefsClient := preload("res://scripts/item_definitions_client.gd") const InventoryClient := preload("res://scripts/inventory_client.gd") const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd") const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain" class MockHttpTransport: extends Node signal request_completed( result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray ) var body_json: String = "" func request( _url: String, _custom_headers: PackedStringArray = PackedStringArray(), _method: HTTPClient.Method = HTTPClient.METHOD_GET, _request_data: String = "" ) -> Error: request_completed.emit( HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer() ) return OK class StubItemDefsClient: extends Node func display_name_for(item_id: String) -> String: if item_id == "survey_drone_kit": return "Survey Drone Kit" return item_id class MockEconomySyncClient: extends Node var sync_count: int = 0 func request_sync_from_server() -> void: sync_count += 1 class PendingSyncHttpTransport: extends Node signal request_completed( result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray ) var request_count: int = 0 var body_json: String = "" func request( _url: String, _custom_headers: PackedStringArray = PackedStringArray(), _method: HTTPClient.Method = HTTPClient.METHOD_GET, _request_data: String = "" ) -> Error: request_count += 1 return OK func complete_pending() -> void: request_completed.emit( HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer() ) func _make_defs_client(transport: Node) -> Node: var c: Node = QuestDefinitionsClient.new() c.set("injected_http", transport) auto_free(transport) auto_free(c) add_child(c) return c func _gather_defs_json() -> String: return ( '{"schemaVersion":1,"quests":[' + '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",' + '"prerequisiteQuestIds":[],"steps":[]}]}' ) func _active_gather_snapshot() -> Dictionary: return { "schemaVersion": 1, "playerId": "dev-local-1", "quests": [ { "questId": QuestHudController.GATHER_QUEST_ID, "status": "active", "currentStepIndex": 0, "objectiveCounters": {"gather_intro_obj_scrap": 2}, } ], } func _completed_gather_with_summary_snapshot() -> Dictionary: return { "schemaVersion": 1, "playerId": "dev-local-1", "quests": [ { "questId": QuestHudController.GATHER_QUEST_ID, "status": "completed", "currentStepIndex": 0, "objectiveCounters": {}, "completedAt": "2026-06-07T12:00:00Z", "completionRewardSummary": { "itemGrants": [], "skillXpGrants": [{"skillId": "salvage", "amount": 25}], }, } ], } func _active_operator_chain_snapshot() -> Dictionary: return { "schemaVersion": 1, "playerId": "dev-local-1", "quests": [ { "questId": OPERATOR_CHAIN_QUEST_ID, "status": "active", "currentStepIndex": 3, "objectiveCounters": {}, } ], } func _completed_operator_chain_with_summary_snapshot() -> Dictionary: return { "schemaVersion": 1, "playerId": "dev-local-1", "quests": [ { "questId": OPERATOR_CHAIN_QUEST_ID, "status": "completed", "currentStepIndex": 3, "objectiveCounters": {}, "completedAt": "2026-06-07T14:00:00Z", "completionRewardSummary": { "itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}], "skillXpGrants": [{"skillId": "salvage", "amount": 50}], "reputationGrants": [ { "factionId": "prototype_faction_grid_operators", "amount": 15, } ], }, } ], } func _survey_drone_item_defs_json() -> String: return ( '{"schemaVersion":1,"items":[{"id":"survey_drone_kit",' + '"displayName":"Survey Drone Kit","inventorySlotKind":"bag"}]}' ) func test_completion_transition_paints_reward_label_with_skill_xp() -> void: # Arrange var controller: Node = QuestHudController.new() var reward_label := Label.new() auto_free(controller) auto_free(reward_label) add_child(controller) controller.set("_reward_label", reward_label) # Act controller.call("_on_progress_received", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_str(reward_label.text).contains("Quest rewards:") assert_str(reward_label.text).contains("Salvage +25 XP") func test_first_success_paints_reward_when_quest_was_active_in_session() -> void: # Arrange var controller: Node = QuestHudController.new() var reward_label := Label.new() auto_free(controller) auto_free(reward_label) add_child(controller) controller.set("_reward_label", reward_label) controller.call("_mark_quest_active_in_session", QuestHudController.GATHER_QUEST_ID) # Act controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_str(reward_label.text).contains("Salvage +25 XP") func test_boot_completed_snapshot_does_not_paint_reward_label() -> void: # Arrange var controller: Node = QuestHudController.new() var reward_label := Label.new() auto_free(controller) auto_free(reward_label) add_child(controller) controller.set("_reward_label", reward_label) # Act controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_str(reward_label.text).is_equal("Quest rewards:\n—") func test_completion_transition_paints_item_grant_with_display_name() -> void: # Arrange var controller: Node = QuestHudController.new() var reward_label := Label.new() var item_defs := StubItemDefsClient.new() auto_free(controller) auto_free(reward_label) auto_free(item_defs) add_child(controller) add_child(item_defs) controller.set("_reward_label", reward_label) controller.set("_item_defs_client", item_defs) # Act controller.call("_on_progress_received", _active_operator_chain_snapshot()) controller.call("_on_progress_received", _completed_operator_chain_with_summary_snapshot()) # Assert assert_str(reward_label.text).contains("Survey Drone Kit ×1") assert_str(reward_label.text).contains("Salvage +50 XP") assert_str(reward_label.text).contains("Grid Operators +15 rep") func test_definitions_ready_repaints_reward_label_with_display_name() -> void: # Arrange var defs_transport := MockHttpTransport.new() defs_transport.body_json = _gather_defs_json() var defs_client: Node = _make_defs_client(defs_transport) var controller: Node = QuestHudController.new() var reward_label := Label.new() auto_free(controller) auto_free(reward_label) add_child(controller) controller.set("_defs_client", defs_client) controller.set("_reward_label", reward_label) controller.call("_on_progress_received", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) assert_str(reward_label.text).contains(QuestHudController.GATHER_QUEST_ID) # Act defs_client.call("request_sync_from_server") controller.call("_on_definitions_ready", []) # Assert assert_str(reward_label.text).contains("Intro: Salvage Run") assert_str(reward_label.text).contains("Salvage +25 XP") assert_str(reward_label.text).not_contains("prototype_quest_gather_intro") func test_item_definitions_ready_repaints_reward_item_display_name() -> void: # Arrange var item_transport := MockHttpTransport.new() item_transport.body_json = _survey_drone_item_defs_json() var item_defs: Node = ItemDefsClient.new() item_defs.set("injected_http", item_transport) auto_free(item_transport) auto_free(item_defs) add_child(item_defs) await get_tree().process_frame var controller: Node = QuestHudController.new() var reward_label := Label.new() auto_free(controller) auto_free(reward_label) add_child(controller) controller.set("_reward_label", reward_label) controller.set("_item_defs_client", item_defs) controller.call("_on_progress_received", _active_operator_chain_snapshot()) controller.call("_on_progress_received", _completed_operator_chain_with_summary_snapshot()) assert_str(reward_label.text).contains("survey_drone_kit") # Act item_defs.call("request_sync_from_server") controller.call("_on_item_definitions_ready", {}) # Assert assert_str(reward_label.text).contains("Survey Drone Kit ×1") assert_str(reward_label.text).not_contains("survey_drone_kit ×1") func test_sync_failure_shows_progress_error_and_idle_reward_label() -> void: # Arrange var controller: Node = QuestHudController.new() var progress_label := Label.new() var reward_label := Label.new() auto_free(controller) auto_free(progress_label) auto_free(reward_label) add_child(controller) controller.set("_progress_label", progress_label) controller.set("_reward_label", reward_label) controller.set("_last_snapshot", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Act controller.call("_on_sync_failed", "HTTP 503") # Assert assert_str(progress_label.text).contains("error — HTTP 503") assert_str(reward_label.text).is_equal("Quest rewards:\n—") func test_completion_transition_emits_economy_refresh_signal() -> void: # Arrange var controller: Node = QuestHudController.new() auto_free(controller) add_child(controller) monitor_signals(controller) # Act controller.call("_on_progress_received", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any()) func test_completion_transition_invokes_economy_refresh_callback() -> void: # Arrange var controller: Node = QuestHudController.new() var inventory_client := MockEconomySyncClient.new() var skill_client := MockEconomySyncClient.new() auto_free(controller) auto_free(inventory_client) auto_free(skill_client) add_child(controller) add_child(inventory_client) add_child(skill_client) controller.set("_inventory_client", inventory_client) controller.set("_skill_progression_client", skill_client) # Act controller.call("_on_progress_received", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_int(inventory_client.sync_count).is_equal(1) assert_int(skill_client.sync_count).is_equal(1) func test_boot_completed_does_not_emit_economy_refresh_signal() -> void: # Arrange var controller: Node = QuestHudController.new() auto_free(controller) add_child(controller) monitor_signals(controller) # Act controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_signal(controller).wait_until(200).is_not_emitted("quest_completion_reward_transition") func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void: # Arrange var inv_transport := PendingSyncHttpTransport.new() var bag_parts: PackedStringArray = PackedStringArray() for i in range(24): bag_parts.append('{"slotIndex":%d,"quantity":0}' % i) inv_transport.body_json = ( ( '{"schemaVersion":1,"playerId":"dev-local-1","bagSlots":[%s],' + '"equipmentSlots":[{"slotIndex":0,"quantity":0}]}' ) % ", ".join(bag_parts) ) var skill_transport := PendingSyncHttpTransport.new() skill_transport.body_json = ( '{"schemaVersion":1,"playerId":"dev-local-1","skills":' + '[{"id":"salvage","xp":0,"level":1}]}' ) var inventory_client: Node = InventoryClient.new() var skill_client: Node = SkillProgressionClient.new() inventory_client.set("injected_http", inv_transport) skill_client.set("injected_http", skill_transport) var controller: Node = QuestHudController.new() auto_free(inv_transport) auto_free(skill_transport) auto_free(inventory_client) auto_free(skill_client) auto_free(controller) add_child(inventory_client) add_child(skill_client) add_child(controller) controller.set("_inventory_client", inventory_client) controller.set("_skill_progression_client", skill_client) inventory_client.call("request_sync_from_server") skill_client.call("request_sync_from_server") # Act controller.call("_on_progress_received", _active_gather_snapshot()) controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) # Assert assert_int(inv_transport.request_count).is_equal(1) assert_int(skill_transport.request_count).is_equal(1) inv_transport.complete_pending() skill_transport.complete_pending() assert_int(inv_transport.request_count).is_equal(2) assert_int(skill_transport.request_count).is_equal(2)