neon-sprawl/client/test/quest_hud_controller_test.gd

713 lines
21 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

extends GdUnitTestSuite
## NEO-122: quest HUD controller accept key + progress label render.
const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
const ItemDefsClient := preload("res://scripts/item_definitions_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 response_code: int = 200
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,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
class PendingAcceptHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
return OK
class FailStartAcceptHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
return ERR_UNAVAILABLE
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
func _make_progress_client(sync_transport: Node, accept_transport: Node = null) -> Node:
var c: Node = QuestProgressClient.new()
c.set("injected_sync_http", sync_transport)
var accept_http: Node = accept_transport if accept_transport != null else sync_transport
c.set("injected_accept_http", accept_http)
auto_free(sync_transport)
if accept_transport != null and accept_transport != sync_transport:
auto_free(accept_transport)
auto_free(c)
add_child(c)
return c
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 _build_controller(
sync_transport: Node, accept_transport: Node, defs_transport: Node
) -> Dictionary:
var controller: Node = QuestHudController.new()
var progress: Node = _make_progress_client(sync_transport, accept_transport)
var defs: Node = _make_defs_client(defs_transport)
var progress_label := Label.new()
var accept_label := Label.new()
auto_free(controller)
auto_free(progress_label)
auto_free(accept_label)
add_child(controller)
await get_tree().process_frame
controller.call(
"setup", progress, defs, progress_label, accept_label, Callable(self, "_noop_http_config")
)
await get_tree().process_frame
return {
"controller": controller,
"progress": progress,
"accept_label": accept_label,
"progress_label": progress_label,
}
func _noop_http_config(_client: Node) -> void:
pass
func test_try_accept_key_input_handles_q_key() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.keycode = KEY_Q
# Act
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["accept_label"].text).contains("sending")
func test_try_accept_key_input_shows_busy_while_accept_in_flight() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.keycode = KEY_Q
built["controller"].call("try_accept_key_input", event)
# Act
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["accept_label"].text).contains("busy")
func test_try_accept_key_input_shows_failed_when_post_wont_start() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), FailStartAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.keycode = KEY_Q
# Act
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["accept_label"].text).contains("could not start")
func test_progress_label_shows_loading_before_snapshot() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
var text: String = str(built["progress_label"].text)
# Assert
assert_str(text).contains("Quests:")
assert_str(text).contains("Loading")
func test_shift_q_shows_progress_loading_before_snapshot() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var accept_label := Label.new()
var progress_client: Node = QuestProgressClient.new()
var defs_client: Node = QuestDefinitionsClient.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
auto_free(defs_client)
add_child(controller)
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_last_snapshot", {})
controller.set("_progress_error", "")
# Act
controller.call("_request_eligible_accept")
# Assert
assert_str(accept_label.text).contains("progress loading")
func test_shift_q_blocks_when_progress_sync_failed() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var accept_label := Label.new()
var progress_client: Node = QuestProgressClient.new()
var defs_client: Node = QuestDefinitionsClient.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
auto_free(defs_client)
add_child(controller)
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_last_snapshot", {})
controller.set("_progress_error", "HTTP 404 (player unknown)")
# Act
controller.call("_request_eligible_accept")
# Assert
assert_str(accept_label.text).contains("progress error")
func test_request_accept_shows_busy_feedback() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var controller: Node = built["controller"]
var accept_label: Label = built["accept_label"]
controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID)
# Act
var started: bool = bool(controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID))
# Assert
assert_bool(started).is_false()
assert_str(accept_label.text).contains("busy")
func test_request_accept_shows_sending_when_post_starts() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var controller: Node = built["controller"]
var accept_label: Label = built["accept_label"]
# Act
var started: bool = bool(controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID))
# Assert
assert_bool(started).is_true()
assert_str(accept_label.text).contains("sending")
func _not_started_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "not_started",
"currentStepIndex": 0,
"objectiveCounters": {},
}
],
}
func test_accept_success_merges_quest_row_into_progress_label() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
# Act
(
controller
. call(
"_on_accept_result_received",
QuestHudController.GATHER_QUEST_ID,
{
"accepted": true,
"quest":
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
},
}
)
)
# Assert
assert_str(progress_label.text).contains("active step")
func test_stale_sync_reapplies_accept_quest_patch() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
(
controller
. set(
"_accept_quest_patch",
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
)
)
# Act
controller.call("_on_progress_received", _not_started_snapshot())
# Assert
assert_str(progress_label.text).contains("active step")
func test_accept_clears_progress_error_and_shows_active_row() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
controller.set("_progress_error", "HTTP 404 (player unknown)")
# Act
(
controller
. call(
"_on_accept_result_received",
QuestHudController.GATHER_QUEST_ID,
{
"accepted": true,
"quest":
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
},
}
)
)
# Assert
assert_str(progress_label.text).contains("active step")
assert_str(progress_label.text).not_contains("error —")
func test_sync_fail_preserves_accept_quest_patch() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
(
controller
. set(
"_accept_quest_patch",
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
)
)
(
controller
. set(
"_last_snapshot",
(
QuestProgressClient
. merge_quest_row_into_snapshot(
_not_started_snapshot(),
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
)
)
)
)
# Act
controller.call("_on_sync_failed", "HTTP 503")
# Assert
assert_str(progress_label.text).contains("active step")
assert_str(progress_label.text).contains("sync error — HTTP 503")
func test_shift_q_shows_definitions_error_when_defs_failed() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var accept_label := Label.new()
var progress_client: Node = QuestProgressClient.new()
var defs_client: Node = QuestDefinitionsClient.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
auto_free(defs_client)
add_child(controller)
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_last_snapshot", _not_started_snapshot())
controller.set("_progress_error", "")
controller.set("_defs_error", "HTTP 503")
# Act
controller.call("_request_eligible_accept")
# Assert
assert_str(accept_label.text).contains("definitions error")
func _gather_defs_json() -> String:
return (
'{"schemaVersion":1,"quests":['
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
+ '"prerequisiteQuestIds":[],"steps":[]}]}'
)
func test_definitions_ready_repaints_with_display_names_when_progress_error() -> void:
# Arrange
var defs_transport := MockHttpTransport.new()
defs_transport.body_json = _gather_defs_json()
var defs_client: Node = _make_defs_client(defs_transport)
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
defs_client.call("request_sync_from_server")
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
controller.set("_progress_error", "HTTP 503")
# Act
controller.call("_on_definitions_ready", [])
# Assert
assert_str(progress_label.text).contains("Intro: Salvage Run")
assert_str(progress_label.text).not_contains("prototype_quest_gather_intro")
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 test_completion_transition_paints_reward_label_with_skill_xp() -> 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)
# 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 — first successful GET already completed (missed intermediate snapshots)
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 _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}],
},
}
],
}
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")
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 _survey_drone_item_defs_json() -> String:
return (
'{"schemaVersion":1,"items":[{"id":"survey_drone_kit",'
+ '"displayName":"Survey Drone Kit","inventorySlotKind":"bag"}]}'
)
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")