287 lines
14 KiB
GDScript
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)
|