Files
aetherbound-guild/runtime/tests/p9_2/controls_and_result_runtime_test.gd
T

287 lines
14 KiB
GDScript

extends SceneTree
const MainScene = preload("res://main.tscn")
const VIEWPORTS := [Vector2i(844, 390), Vector2i(1280, 720), Vector2i(390, 844)]
const OUTCOMES := {
"victory": "all_enemies_defeated",
"defeat": "all_members_fallen",
"timeout": "encounter_timeout",
"retreat": "withdrawal_complete",
}
var game: Control
var failures: Array[String] = []
var touch_count := 0
var fallback_count := 0
var direct_count := 0
var layout_fixtures := 0
var capture_root := ""
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
capture_root = OS.get_environment("ABG_P9_2_CONTROLS_CAPTURE_ROOT")
await _configure_interaction_window()
game = MainScene.instantiate()
game.auto_boot = false
game.authoritative_auto_advance = false
game.audio_enabled = false
root.add_child(game)
await settle(8)
_prepare_p9_1_result(game)
_expect(game.start_authoritative_battle(), "fixture starts P9.2 battle")
await settle(5)
await _verify_touch_controls()
await _destroy_game()
await _verify_result_layout_matrix()
if failures.is_empty():
print("ABG_P9_2_CONTROLS_RESULT_RUNTIME_OK touch=%d fallback=%d direct=%d pause=true speed=1x,2x inspect=readonly retreat_tick=50 duplicate_receipt=true withdrawal_ticks=20 results=4 lines=5 layouts=%d viewports=844x390,1280x720,390x844 locales=zh_CN,en text_130=true casualty=false" % [touch_count, fallback_count, direct_count, layout_fixtures])
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _configure_interaction_window() -> void:
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
root.content_scale_size = Vector2i(844, 390)
root.size = Vector2i(844, 390)
DisplayServer.window_set_size(Vector2i(844, 390))
await process_frame
func _verify_touch_controls() -> void:
var initial_hash: int = game.authoritative_battle_service.state.state_hash()
var retreat: Button = find_control(game, "P92RetreatButton")
_expect(retreat != null and retreat.disabled, "retreat is visibly disabled before tick 50")
_expect(retreat != null and int(retreat.get_meta("unlock_tick", -1)) == 50, "retreat names its exact unlock tick")
await capture("01-controls-locked")
var tick_before_pause: int = game.authoritative_battle_service.state.tick
await tap("P92PauseButton")
_expect(game.authoritative_battle_service.state.paused, "Pause uses parsed touch")
game.advance_authoritative_ticks(7)
_expect(game.authoritative_battle_service.state.tick == tick_before_pause, "Pause freezes accepted simulation ticks")
await tap("P92PauseButton")
_expect(not game.authoritative_battle_service.state.paused, "Resume uses parsed touch")
var hash_before_speed: int = game.authoritative_battle_service.state.state_hash()
await tap("P92Speed2Button")
_expect(game.authoritative_speed_multiplier == 2, "2x uses parsed touch")
_expect(game.authoritative_battle_service.state.state_hash() == hash_before_speed, "2x is presentation-only")
game.authoritative_auto_advance = true
var tick_before_wall_time: int = game.authoritative_battle_service.state.tick
game._process_authoritative_battle(0.1)
_expect(game.authoritative_battle_service.state.tick == tick_before_wall_time + 2, "2x requests two fixed ticks per 0.1 seconds")
game.authoritative_auto_advance = false
await settle(3)
await capture("02-speed-2x")
var hash_after_2x: int = game.authoritative_battle_service.state.state_hash()
await tap("P92Speed1Button")
_expect(game.authoritative_speed_multiplier == 1, "1x uses parsed touch")
_expect(game.authoritative_battle_service.state.state_hash() == hash_after_2x, "1x selection is presentation-only")
game.authoritative_auto_advance = true
tick_before_wall_time = game.authoritative_battle_service.state.tick
game._process_authoritative_battle(0.1)
_expect(game.authoritative_battle_service.state.tick == tick_before_wall_time + 1, "1x requests one fixed tick per 0.1 seconds")
game.authoritative_auto_advance = false
await settle(3)
var snapshot_before_inspect: Dictionary = game.authoritative_battle_service.state.current_snapshot()
await tap("P92InspectButton")
_expect(game.authoritative_inspect_open, "Inspect opens through parsed touch")
_expect(find_control(game, "P92InspectPanel") != null, "Inspect shows the authoritative event panel")
_expect(game.authoritative_battle_service.state.current_snapshot() == snapshot_before_inspect, "Inspect mutates no domain state")
await capture("03-inspect")
await tap("P92InspectButton")
_expect(not game.authoritative_inspect_open, "Inspect closes through parsed touch")
_expect(game.authoritative_battle_service.state.current_snapshot() == snapshot_before_inspect, "closing Inspect mutates no domain state")
game.advance_authoritative_ticks(50 - game.authoritative_battle_service.state.tick)
await settle(3)
retreat = find_control(game, "P92RetreatButton")
_expect(retreat != null and not retreat.disabled, "retreat becomes visibly available at tick 50")
var event_hash_before_retreat: int = game.authoritative_battle_service.state.event_hash()
await tap("P92RetreatButton")
var first_receipt: Dictionary = game.authoritative_retreat_receipt.duplicate(true)
_expect(first_receipt == {"tick": 50, "resolves_at": 70}, "retreat records the exact 20-tick receipt")
_expect(game.authoritative_battle_service.state.event_hash() != event_hash_before_retreat, "first retreat appends one authoritative event")
await capture("04-retreat-pending")
var event_hash_after_first: int = game.authoritative_battle_service.state.event_hash()
await tap("P92RetreatButton")
_expect(game.authoritative_last_command_code == "retreat_already_requested", "duplicate retreat reports the original command")
_expect(game.authoritative_retreat_receipt == first_receipt, "duplicate retreat preserves the original receipt")
_expect(game.authoritative_battle_service.state.event_hash() == event_hash_after_first, "duplicate retreat appends no second event")
game.advance_authoritative_ticks(19)
_expect(not game.authoritative_battle_service.state.terminal, "retreat remains pending for 19 accepted ticks")
game.advance_authoritative_ticks(1)
await settle(4)
_expect(game.authoritative_battle_service.state.outcome == "retreat", "retreat resolves on the twentieth accepted tick")
_verify_result_grammar(game, "retreat-touch")
_verify_buttons(game.authoritative_battle_presenter.root, Rect2(Vector2.ZERO, Vector2(844, 390)), "retreat-touch")
await capture("05-retreat-result")
await tap("P92RetryButton")
_expect(game.authoritative_battle_active and game.authoritative_battle_service.state.tick == 0, "Retry starts the same source-bound encounter")
_expect(game.authoritative_battle_service.state.state_hash() == initial_hash, "Retry restores the initial deterministic domain state")
_fixture_terminal(game, "retreat", "withdrawal_complete")
var no_events: Array[Dictionary] = []
game.authoritative_battle_presenter.render(game.authoritative_battle_service.state.current_snapshot(), no_events)
await settle(3)
await tap("P92HomeButton")
_expect(not game.authoritative_battle_active, "Home exits P9.2")
_expect(game.facade.visible and game.page == game.AppPage.MAIN_MENU, "Home returns to the visible main menu")
func _verify_result_layout_matrix() -> void:
var fixtures := [
{"size": VIEWPORTS[0], "locale": "zh_CN", "scale": 1.0},
{"size": VIEWPORTS[1], "locale": "en", "scale": 1.3},
{"size": VIEWPORTS[2], "locale": "zh_CN", "scale": 1.3},
]
for fixture in fixtures:
for outcome in OUTCOMES:
await _verify_result_fixture(fixture, String(outcome), String(OUTCOMES[outcome]))
func _verify_result_fixture(fixture: Dictionary, outcome: String, cause: String) -> void:
var viewport := SubViewport.new()
viewport.size = fixture.size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var fixture_game: Control = MainScene.instantiate()
fixture_game.auto_boot = false
fixture_game.authoritative_auto_advance = false
fixture_game.audio_enabled = false
viewport.add_child(fixture_game)
await settle(7)
fixture_game.locale = fixture.locale
fixture_game.text_scale = fixture.scale
_prepare_p9_1_result(fixture_game)
_expect(fixture_game.start_authoritative_battle(), "layout fixture starts")
_fixture_terminal(fixture_game, outcome, cause)
var no_events: Array[Dictionary] = []
fixture_game.authoritative_battle_presenter.render(fixture_game.authoritative_battle_service.state.current_snapshot(), no_events)
await settle(4)
layout_fixtures += 1
var label := "%sx%s/%s/%s/%s" % [fixture.size.x, fixture.size.y, fixture.locale, fixture.scale, outcome]
_verify_result_grammar(fixture_game, label)
_verify_buttons(fixture_game.authoritative_battle_presenter.root, Rect2(Vector2.ZERO, Vector2(fixture.size)), label)
_verify_visible_bounds(fixture_game.authoritative_battle_presenter.root, Rect2(Vector2.ZERO, Vector2(fixture.size)), label)
fixture_game.queue_free()
viewport.queue_free()
await settle(45)
func _fixture_terminal(fixture_game: Control, outcome: String, cause: String) -> void:
var state = fixture_game.authoritative_battle_service.state
state.tick = 70 if outcome == "retreat" else (900 if outcome == "timeout" else 82)
state.terminal = true
state.outcome = outcome
state.outcome_cause = cause
if outcome == "defeat":
for member_id in state.member_ids:
state.member_hp[member_id] = 0
elif outcome == "victory":
for enemy_id in state.enemy_ids:
state.enemy_hp[enemy_id] = 0
elif outcome == "retreat":
state.retreat_requested_tick = 50
state.retreat_resolution_tick = 70
state.revision += 1
_expect(state.invariants_hold(), "%s result fixture preserves domain invariants" % outcome)
func _verify_result_grammar(target_game: Control, label: String) -> void:
var result_panel: Control = find_control(target_game, "P92ResultPanel")
_expect(result_panel != null, "%s shows result panel" % label)
for line_index in range(1, 6):
var line: Label = find_control(target_game, "P92ResultLine%d" % line_index)
_expect(line != null and not line.text.strip_edges().is_empty(), "%s shows result line %d" % [label, line_index])
if line != null:
_expect(int(line.get_meta("grammar_index", 0)) == line_index, "%s preserves result grammar order %d" % [label, line_index])
var next_line: Label = find_control(target_game, "P92ResultLine5")
if next_line != null:
_expect("永久伤亡" in next_line.text or "no permanent casualty" in next_line.text, "%s explicitly rejects permanent casualty" % label)
_expect(find_control(target_game, "P92HomeButton") != null and find_control(target_game, "P92RetryButton") != null, "%s shows Home and Retry" % label)
func _prepare_p9_1_result(target_game: Control) -> void:
target_game.locale = "zh_CN" if target_game.locale.is_empty() else target_game.locale
target_game.muted = true
target_game.facade.session_state.field_order.assign(["Hearthguard", "Farstring", "Kindhand"])
target_game.facade.session_state.starting_pair.assign(["Hearthguard", "Farstring"])
target_game.facade.session_state.front_choice = "Hearthguard"
target_game.facade.session_state.rear_choice = "Farstring"
target_game.facade.session_state.added_rear = "Kindhand"
target_game.facade.session_state.member_hp = {"Hearthguard": 72, "Farstring": 46, "Kindhand": 85}
target_game.facade.session_state.member_levels = {"Hearthguard": 2, "Farstring": 1, "Kindhand": 1}
target_game.page = target_game.AppPage.THIRD_CYCLE_RESULT
func tap(node_name: String) -> void:
var control: Control = find_control(game, node_name)
if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled):
failures.append("P9.2 controls: unavailable touch target %s" % node_name)
return
var position := root.get_final_transform() * control.get_global_rect().get_center()
var down := InputEventScreenTouch.new()
down.index = 0
down.position = position
down.pressed = true
Input.parse_input_event(down)
await process_frame
var up := InputEventScreenTouch.new()
up.index = 0
up.position = position
up.pressed = false
Input.parse_input_event(up)
touch_count += 1
await settle(7)
func find_control(node: Node, node_name: String):
if node.name == node_name and node is Control and not node.is_queued_for_deletion():
return node
for child in node.get_children():
var found = find_control(child, node_name)
if found != null:
return found
return null
func _verify_buttons(node: Node, bounds: Rect2, label: String) -> void:
if node is BaseButton and node.is_visible_in_tree():
var rect: Rect2 = node.get_global_rect()
_expect(_contains(bounds, rect), "%s button %s stays in viewport: %s" % [label, node.name, rect])
_expect(rect.size.x >= 44.0 and rect.size.y >= 44.0, "%s button %s is at least 44px: %s" % [label, node.name, rect.size])
for child in node.get_children():
_verify_buttons(child, bounds, label)
func _verify_visible_bounds(node: Node, bounds: Rect2, label: String) -> void:
if node is Control and node.is_visible_in_tree() and node.name in ["P92BattleTitle", "P92TickStatus", "P92ResultPanel", "P92ResultActions"]:
_expect(_contains(bounds, node.get_global_rect()), "%s %s stays in viewport: %s" % [label, node.name, node.get_global_rect()])
for child in node.get_children():
_verify_visible_bounds(child, bounds, label)
func _contains(bounds: Rect2, rect: Rect2) -> bool:
return rect.position.x >= bounds.position.x - 0.5 and rect.position.y >= bounds.position.y - 0.5 and rect.end.x <= bounds.end.x + 0.5 and rect.end.y <= bounds.end.y + 0.5
func _destroy_game() -> void:
if game != null:
game.queue_free()
game = null
await settle(60)
func capture(name: String) -> void:
if capture_root.is_empty():
return
DirAccess.make_dir_recursive_absolute(capture_root)
await settle(3)
await RenderingServer.frame_post_draw
var image := root.get_texture().get_image()
var error := image.save_png(capture_root.path_join(name + ".png"))
_expect(error == OK, "capture %s succeeds" % name)
func settle(frames: int) -> void:
for _frame in frames:
await process_frame
func _expect(condition: bool, message: String) -> void:
if not condition and failures.size() < 160:
failures.append("P9.2 controls/result: %s" % message)