333 lines
12 KiB
GDScript
333 lines
12 KiB
GDScript
extends SceneTree
|
|
|
|
const MainScene = preload("res://main.tscn")
|
|
const FacadeScript = preload("res://compat/p9_0_root_facade.gd")
|
|
const VIEWPORTS := [Vector2i(844, 390), Vector2i(1280, 720), Vector2i(390, 844)]
|
|
const PROFILES := ["normal", "active", "efficient", "adversarial", "returning"]
|
|
const TOUCHES_PER_PROFILE := 38
|
|
|
|
var failures: Array[String] = []
|
|
var viewport: SubViewport
|
|
var game: Control
|
|
var touch_count := 0
|
|
var drag_count := 0
|
|
var fallback_count := 0
|
|
var direct_count := 0
|
|
var captures_root := ""
|
|
var profile_results: Dictionary = {}
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run_test")
|
|
|
|
|
|
func run_test() -> void:
|
|
captures_root = OS.get_environment("ABG_P9_1_RUNTIME_CAPTURE_ROOT")
|
|
var recording_only := OS.get_environment("ABG_P9_1_RUNTIME_RECORDING_ONLY") == "1"
|
|
var active_profiles: Array[String] = ["normal"]
|
|
if not recording_only:
|
|
active_profiles.assign(PROFILES)
|
|
else:
|
|
await process_frame
|
|
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
|
|
var interaction_viewport := Vector2i(root.get_visible_rect().size)
|
|
for profile in active_profiles:
|
|
await run_profile(profile, profile == "normal")
|
|
await destroy_game()
|
|
_assert_profile_results(active_profiles)
|
|
if not recording_only:
|
|
await verify_layout_matrix()
|
|
if failures.is_empty():
|
|
if recording_only:
|
|
print("ABG_P9_1_RUNTIME_RECORDING_OK viewport=%sx%s touch=%d drag=%d fallback=%d direct=%d final=true restart=true" % [interaction_viewport.x, interaction_viewport.y, touch_count, drag_count, fallback_count, direct_count])
|
|
else:
|
|
print("ABG_P9_1_FULL_FIRST_SESSION_RUNTIME_OK profiles=%d touch=%d drag=%d fallback=%d direct=%d root=%sx%s layout_matrix=844x390,1280x720,390x844 locales=zh_CN,en text_130=true reduced_motion=true mute_captions=true low_power=true restart=true" % [active_profiles.size(), touch_count, drag_count, fallback_count, direct_count, interaction_viewport.x, interaction_viewport.y])
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func run_profile(profile: String, capture: bool) -> void:
|
|
game = MainScene.instantiate()
|
|
game.auto_boot = false
|
|
game.audio_enabled = false
|
|
root.add_child(game)
|
|
await settle(10)
|
|
game.locale = "zh_CN"
|
|
game.muted = true
|
|
game.boot_verified = true
|
|
game.page = game.AppPage.TITLE
|
|
game.render()
|
|
await settle(6)
|
|
var touches_before := touch_count
|
|
var decision_times: Array[float] = []
|
|
var last_decision := Time.get_ticks_msec() / 1000.0
|
|
await tap("EnterGameButton")
|
|
await tap("StartNewGameButton")
|
|
await tap("OpenRecruitSelectionButton")
|
|
await drag_without_selection()
|
|
await tap("Recruit_Wayrunner")
|
|
await tap("Recruit_Farstring")
|
|
await tap("Recruit_Wayrunner")
|
|
await tap("Recruit_Kindhand")
|
|
await tap("ReviewFormationButton")
|
|
await tap("ReviseRecruitSelectionButton")
|
|
await tap("Recruit_Farstring")
|
|
await tap("Recruit_Hearthguard")
|
|
await tap("ReviewFormationButton")
|
|
await tap("CommitFormationButton")
|
|
decision_times.append(_decision_gap(last_decision))
|
|
last_decision = Time.get_ticks_msec() / 1000.0
|
|
_assert_page(game.AppPage.BATTLE, "%s enters battle one" % profile)
|
|
await wait_for_page(game.AppPage.VICTORY, profile)
|
|
if capture:
|
|
await capture_state("01-result-one")
|
|
await tap("ContinueAdaptationButton")
|
|
await tap("Adaptation_EQ_061")
|
|
await tap("Adaptation_EQ_002")
|
|
await tap("BeginSecondBattle")
|
|
decision_times.append(_decision_gap(last_decision))
|
|
last_decision = Time.get_ticks_msec() / 1000.0
|
|
await wait_for_page(game.AppPage.SECOND_RESULT, profile)
|
|
if capture:
|
|
await capture_state("02-result-two")
|
|
await tap("OpenGuildReturnButton")
|
|
await tap("GuildChoice_retain_item")
|
|
await tap("GuildChoice_recruit_rear")
|
|
await tap("BeginThirdBattle")
|
|
decision_times.append(_decision_gap(last_decision))
|
|
last_decision = Time.get_ticks_msec() / 1000.0
|
|
await wait_for_page(game.AppPage.SESSION_RESULT, profile)
|
|
if capture:
|
|
await capture_state("03-result-three")
|
|
await tap("OpenRouteBoardButton")
|
|
await tap("Route_R6_01")
|
|
await tap("Route_R6_03")
|
|
await tap("ReviewRouteButton")
|
|
await tap("CancelRouteReviewButton")
|
|
await tap("Route_R6_02")
|
|
await tap("ReviewRouteButton")
|
|
await tap("CommitRouteButton")
|
|
decision_times.append(_decision_gap(last_decision))
|
|
last_decision = Time.get_ticks_msec() / 1000.0
|
|
await wait_for_page(game.AppPage.SECOND_CYCLE_RESULT, profile)
|
|
if capture:
|
|
await capture_state("04-result-four")
|
|
await tap("OpenTrainingButton")
|
|
await tap("Training_Rear")
|
|
await tap("ReviewTrainingButton")
|
|
await tap("CancelTrainingReviewButton")
|
|
await tap("Training_Front")
|
|
await tap("ReviewTrainingButton")
|
|
await tap("CommitTrainingButton")
|
|
decision_times.append(_decision_gap(last_decision))
|
|
last_decision = Time.get_ticks_msec() / 1000.0
|
|
await wait_for_page(game.AppPage.THIRD_CYCLE_RESULT, profile)
|
|
_assert_page(game.AppPage.THIRD_CYCLE_RESULT, "%s reaches final result" % profile)
|
|
var completed_snapshot: Dictionary = game.facade.session_state.snapshot()
|
|
if capture:
|
|
await capture_state("05-final-result")
|
|
await tap("ReplayGrowthButton")
|
|
await tap("RestartConfirmButton")
|
|
_assert_page(game.AppPage.MAIN_MENU, "%s restart returns to Main Menu" % profile)
|
|
_assert_clean_restart(profile)
|
|
profile_results[profile] = {
|
|
"decision_gaps": decision_times.duplicate(),
|
|
"max_gap": decision_times.max(),
|
|
"touch_count": touch_count,
|
|
"completed_snapshot": completed_snapshot,
|
|
"final_coin": game.coin,
|
|
"final_page": game.page,
|
|
}
|
|
expect(touch_count - touches_before == TOUCHES_PER_PROFILE, "%s uses the exact visible touch trace" % profile)
|
|
print("ABG_P9_1_PROFILE_RESULT profile=%s completed_coin=%d restart_page=%d decisions=%s max_gap=%.2f" % [profile, int(completed_snapshot["resources"]["coin"]), game.page, decision_times, decision_times.max()])
|
|
if decision_times.max() > 8.0:
|
|
failures.append("%s longest decision gap %.2fs exceeds 8s" % [profile, decision_times.max()])
|
|
|
|
|
|
func wait_for_page(target_page: int, profile: String) -> void:
|
|
var previous_scale := Engine.time_scale
|
|
Engine.time_scale = {
|
|
"normal": 8.0,
|
|
"active": 12.0,
|
|
"efficient": 16.0,
|
|
"adversarial": 6.0,
|
|
"returning": 10.0,
|
|
}.get(profile, 8.0)
|
|
var frame_limit := 900
|
|
for frame in range(frame_limit):
|
|
if game.page == target_page:
|
|
break
|
|
await process_frame
|
|
Engine.time_scale = previous_scale
|
|
_assert_page(target_page, "%s automatic battle reaches page %d" % [profile, target_page])
|
|
await settle(6)
|
|
|
|
|
|
func verify_layout_matrix() -> void:
|
|
for fixture in [
|
|
{"size": VIEWPORTS[0], "locale": "zh_CN", "scale": 1.0, "motion": false, "low_power": false},
|
|
{"size": VIEWPORTS[1], "locale": "en", "scale": 1.3, "motion": true, "low_power": false},
|
|
{"size": VIEWPORTS[2], "locale": "zh_CN", "scale": 1.3, "motion": true, "low_power": true},
|
|
]:
|
|
var test_viewport := SubViewport.new()
|
|
test_viewport.size = fixture["size"]
|
|
test_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
root.add_child(test_viewport)
|
|
var fixture_game := MainScene.instantiate()
|
|
fixture_game.auto_boot = false
|
|
fixture_game.audio_enabled = false
|
|
test_viewport.add_child(fixture_game)
|
|
await settle(8)
|
|
fixture_game.boot_verified = true
|
|
fixture_game.locale = fixture["locale"]
|
|
fixture_game.text_scale = fixture["scale"]
|
|
fixture_game.reduced_motion = fixture["motion"]
|
|
fixture_game.muted = true
|
|
fixture_game.facade.presentation_settings.low_power = fixture["low_power"]
|
|
fixture_game.page = fixture_game.AppPage.MAIN_MENU
|
|
fixture_game.render()
|
|
await settle(4)
|
|
verify_buttons(fixture_game, Rect2(Vector2.ZERO, fixture["size"]), str(fixture["size"]))
|
|
fixture_game.queue_free()
|
|
test_viewport.queue_free()
|
|
await settle(45)
|
|
|
|
|
|
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()
|
|
if not (rect.position.x >= -0.5 and rect.position.y >= -0.5 and rect.end.x <= bounds.end.x + 0.5 and rect.end.y <= bounds.end.y + 0.5):
|
|
failures.append("%s button %s outside viewport: %s" % [label, node.name, rect])
|
|
if rect.size.x < 44.0 or rect.size.y < 44.0:
|
|
failures.append("%s button %s below 44px: %s" % [label, node.name, rect.size])
|
|
for child in node.get_children():
|
|
verify_buttons(child, bounds, label)
|
|
|
|
|
|
func tap(node_name: String) -> void:
|
|
var 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("unavailable touch target %s on page %s" % [node_name, game.page])
|
|
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 drag_without_selection() -> void:
|
|
var selection_before: Array[String] = game.selected_recruits.duplicate()
|
|
var start := root.get_final_transform() * Vector2(330.0, 112.0)
|
|
var finish := root.get_final_transform() * Vector2(370.0, 112.0)
|
|
var down := InputEventScreenTouch.new()
|
|
down.index = 0
|
|
down.position = start
|
|
down.pressed = true
|
|
Input.parse_input_event(down)
|
|
await process_frame
|
|
var drag := InputEventScreenDrag.new()
|
|
drag.index = 0
|
|
drag.position = finish
|
|
drag.relative = finish - start
|
|
drag.velocity = Vector2(240.0, 0.0)
|
|
Input.parse_input_event(drag)
|
|
await process_frame
|
|
var up := InputEventScreenTouch.new()
|
|
up.index = 0
|
|
up.position = finish
|
|
up.pressed = false
|
|
Input.parse_input_event(up)
|
|
drag_count += 1
|
|
await settle(4)
|
|
expect(game.selected_recruits == selection_before, "neutral ScreenDrag does not select a Recruit")
|
|
|
|
|
|
func capture_state(state_name: String) -> void:
|
|
if captures_root.is_empty():
|
|
return
|
|
DirAccess.make_dir_recursive_absolute(captures_root)
|
|
await settle(3)
|
|
await RenderingServer.frame_post_draw
|
|
var image := root.get_texture().get_image()
|
|
var error := image.save_png(captures_root.path_join(state_name + ".png"))
|
|
if error != OK:
|
|
failures.append("capture %s failed with %s" % [state_name, error])
|
|
|
|
|
|
func _assert_profile_results(profiles: Array[String]) -> void:
|
|
var expected: Dictionary = {}
|
|
for profile in profiles:
|
|
expect(profile_results.has(profile), "profile %s reports completion" % profile)
|
|
if profile_results.has(profile):
|
|
expect(int(profile_results[profile]["final_coin"]) == 0, "%s restart clears Coin" % profile)
|
|
expect(int(profile_results[profile]["final_page"]) == FacadeScript.AppPage.MAIN_MENU, "%s restart ends at Main Menu" % profile)
|
|
if profile_results.has(profile):
|
|
var completed: Dictionary = profile_results[profile]["completed_snapshot"]
|
|
if expected.is_empty():
|
|
expected = completed
|
|
else:
|
|
expect(completed == expected, "%s domain snapshot matches normal profile" % profile)
|
|
|
|
|
|
func _assert_clean_restart(profile: String) -> void:
|
|
expect(game.selected_recruits.is_empty(), "%s restart clears selected recruits" % profile)
|
|
expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "%s restart clears party" % profile)
|
|
expect(game.coin == 0 and not game.battle_playing, "%s restart clears transient battle" % profile)
|
|
|
|
|
|
func _decision_gap(last_time: float) -> float:
|
|
return maxf(0.0, Time.get_ticks_msec() / 1000.0 - last_time)
|
|
|
|
|
|
func _assert_page(expected: int, message: String) -> void:
|
|
if game.page != expected:
|
|
failures.append("%s (actual page %s)" % [message, game.page])
|
|
|
|
|
|
func destroy_game() -> void:
|
|
if game != null and is_instance_valid(game):
|
|
game.queue_free()
|
|
game = null
|
|
if viewport != null and is_instance_valid(viewport):
|
|
viewport.queue_free()
|
|
viewport = null
|
|
await settle(60)
|
|
|
|
|
|
func find_control(node: Node, node_name: String) -> Control:
|
|
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 settle(frames: int) -> void:
|
|
for frame in range(frames):
|
|
await process_frame
|
|
|
|
|
|
func expect(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append("P9.1 full first-session runtime: %s" % message)
|