Files
mtgodot-poc/project/app_flow_lifecycle_test.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

236 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。
# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycleGameScene 不重复创建。
extends SceneTree
const AppFlow = preload("res://app_flow.gd")
class FakeClient extends Node:
signal phase_changed(phase: String)
signal disconnected(reason: String)
signal char_list(characters: Array)
signal char_name_changed(pid: int, name: String)
signal entered_game()
signal login_failed(reason: String)
signal connect_failed(reason: String)
signal server_quit_requested()
signal char_created(slot: int)
signal char_create_failed(reason: int)
signal char_deleted(slot: int)
signal char_delete_failed()
var selects := []
var reconnects := 0
var connect_calls := 0
var retry_login_calls := 0
func select_character(index: int) -> bool:
selects.append(index)
return true
func reconnect() -> bool:
reconnects += 1
return true
func connect_to_server(_auth_host: String, _auth_port: int, _game_host: String,
_game_port: int, _id: String, _pw: String) -> void:
connect_calls += 1
func retry_login(_auth_host: String, _auth_port: int, _game_host: String,
_game_port: int, _id: String, _pw: String) -> void:
retry_login_calls += 1
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
var client := FakeClient.new()
var flow := AppFlow.new()
flow.build_game_scene = false
get_root().add_child(flow)
flow.start("", client)
await process_frame
var lifecycle_nodes: Array[Node] = []
for child in flow.get_children():
if child.name == "AppLifecycle":
lifecycle_nodes.append(child)
_ck(lifecycle_nodes.size() == 1, "AppFlow owns exactly one AppLifecycle")
_ck(flow.get_node_or_null("AppLifecycle") != null,
"AppLifecycle survives login screen construction")
_ck(flow._lifecycle.close_requested.is_connected(Callable(flow, "_on_close_requested")),
"AppFlow consumes the desktop close request")
var flow_file := FileAccess.open("res://app_flow.gd", FileAccess.READ)
var flow_source: String = flow_file.get_as_text() if flow_file else ""
var game_start: int = flow_source.find("func _goto_game()")
var game_end: int = flow_source.find("# --- 登录表单", game_start)
var game_body: String = flow_source.substr(game_start, game_end - game_start) if game_start >= 0 and game_end > game_start else ""
_ck(not game_body.contains("client.net_poll()"),
"AppFlow does not re-enter the M2Client frame pump while waiting for the model")
# A 40250 GC_WARP is a replacement transport, not a terminal login error.
# When the replacement connect fails synchronously, the native bridge must
# retain the reconnect owner before returning to LoginPhase; otherwise the
# next reconnect() call sees GameLogin and is incorrectly treated as a
# duplicate in-flight request. Keep this source-level guard beside the
# phase-only AppFlow behavior test so the native invariant cannot regress
# while the Godot fake client still exercises the UI owner.
var m2_path := ProjectSettings.globalize_path("res://../extension/src/net/m2_client.cpp")
var m2_file := FileAccess.open(m2_path, FileAccess.READ)
var m2_source: String = m2_file.get_as_text() if m2_file else ""
var first_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(")
var first_warp_body: String = m2_source.substr(first_warp_start, 700) \
if first_warp_start >= 0 else ""
var second_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(", first_warp_start + 1)
var second_warp_body: String = m2_source.substr(second_warp_start, 700) \
if second_warp_start >= 0 else ""
_ck(not first_warp_body.contains("classic_remote_disconnect_pending = true")
and not first_warp_body.contains("emit_signal(\"disconnected\""),
"fast DirectEnter connect failure stays on the ClosePhase owner")
_ck(not second_warp_body.contains("classic_remote_disconnect_pending = true")
and not second_warp_body.contains("emit_signal(\"disconnected\""),
"GC_WARP connect failure stays on the ClosePhase owner")
# 40250 keeps RecvPhasePacket(PHASE_CLOSE) separate from
# OnRemoteDisconnect: ClosePhase -> SetLoginPhase is a phase-owner change,
# not a transport failure. The native bridge must therefore not synthesize a
# disconnected signal or arm the remote-disconnect retry owner here.
var phase_close_start: int = m2_source.find("classic_sess->on_phase_close = [this]()")
var phase_close_end: int = m2_source.find("classic_sess->on_phase_leave", phase_close_start)
var phase_close_body: String = m2_source.substr(phase_close_start,
phase_close_end - phase_close_start) if phase_close_start >= 0 and phase_close_end > phase_close_start else ""
_ck(not phase_close_body.contains("classic_remote_disconnect_pending = true")
and not phase_close_body.contains("emit_signal(\"disconnected\""),
"explicit PHASE_CLOSE does not synthesize transport disconnected")
# An empty 40250 account is a valid SelectPhase result. The live probe must
# be able to verify that packet/UI owner without treating the absence of a
# selectable slot as a login failure or trying to enter a stale slot.
var e2e_path := ProjectSettings.globalize_path("res://../extension/tools/net_classic_e2e.cpp")
var e2e_file := FileAccess.open(e2e_path, FileAccess.READ)
var e2e_source: String = e2e_file.get_as_text() if e2e_file else ""
_ck(e2e_source.contains("MT_CLASSIC_EXPECT_EMPTY")
and e2e_source.contains("PASS EMPTY CHARACTER LIST"),
"live probe treats an all-empty character list as a valid SelectPhase result")
_ck(client.get_parent() == flow,
"AppFlow keeps the same client beside lifecycle coordinator")
var login_snapshot: Dictionary = flow.get_playable_snapshot()
_ck(login_snapshot.get("stage", "") == "LOGIN" and not login_snapshot.get("scene_ready", true),
"playable snapshot is a read-only LOGIN summary")
# 40250 intrologin.OnLoginFailure closes only the connecting dialog. The
# LoginPhase window remains the owner of the retry, including its password
# field and status surface; rebuilding the whole layer changes the phase
# owner and can strand a pending retry callback on the old UI.
var login_ui_before: Node = flow._ui
var login_fields: Array[Node] = flow._ui.find_children("*", "LineEdit", true, false)
var password_before: Node = login_fields[1] if login_fields.size() > 1 else null
client.login_failed.emit("WRONGPWD")
await process_frame
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
"login failure keeps the existing LoginPhase UI for retry")
_ck(password_before != null, "login UI has a password field for retry focus")
var login_status := flow._ui.find_child("Status", true, false)
_ck(login_status != null and String(login_status.text).contains("WRONGPWD"),
"login failure is rendered in the existing status surface")
client.connect_failed.emit("ECONNREFUSED")
await process_frame
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
"game connect failure keeps the existing LoginPhase UI for retry")
_ck(login_status != null and String(login_status.text).contains("ECONNREFUSED"),
"game connect failure uses the transport status callback, not credential failure")
# 40250 LoginWindow.Connect reuses the existing MainStream owner and only
# replaces the AccountConnector transport after a credential failure. The
# AppFlow retry must therefore use the bridge's retry_login seam instead of
# treating a still-owned LoginPhase as a full disconnect/rebuild.
flow._do_connect()
_ck(client.retry_login_calls == 1 and client.connect_calls == 0,
"login form retry replaces only the authentication transport")
var retry_start: int = m2_source.find("void M2Client::retry_login")
var retry_end: int = m2_source.find("void M2Client::disconnect_from_server", retry_start)
var retry_body: String = m2_source.substr(retry_start, retry_end - retry_start) \
if retry_start >= 0 and retry_end > retry_start else ""
_ck(retry_body.contains("classic_sess->retry_login")
and not retry_body.contains("disconnect_from_server()"),
"native LoginPhase retry does not rebuild the whole connection owner")
# 40250 CPythonNetworkStream::OnRemoteDisconnect calls SetLoginPhase.
# SetLoginPhase replaces GameWindow with LoginWindow; it does not retain the
# GameScene behind a reconnect mask or auto-select a remembered slot.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
flow._selected_char_slot = 2
client.disconnected.emit("wifi lost")
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
and flow._game == null,
"game peer close returns to LoginPhase and releases GameScene")
_ck(flow._reconnect == null or not flow._reconnect.is_showing(),
"game peer close does not retain a reconnect overlay")
client.char_list.emit([{"index": 2, "name": "Hero"}])
_ck(flow.state() == AppFlow.SELECT and flow._game == null and client.selects.is_empty(),
"post-disconnect character list enters SELECT without phantom re-entry")
# 40250 PHASE_CLOSE -> ClosePhase -> SetLoginPhase has the same LoginPhase
# owner even when the bridge reports only the phase observer event.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
flow._reconnecting_game = false
client.phase_changed.emit("login")
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
and flow._game == null
and (flow._reconnect == null or not flow._reconnect.is_showing()),
"game-side PHASE_CLOSE transition returns to LoginPhase owner")
# The explicit PHASE_CLOSE spelling must use the same owner path as the
# native phase-only login notification.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
client.phase_changed.emit("close")
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
and flow._game == null,
"explicit PHASE_CLOSE observer releases GameScene for LoginPhase")
# 40250 SetSelectCharacterPhase still owns the UI when LoginPhase receives an
# empty character list. The empty list must remain SELECT and never select a
# stale slot.
flow._state = AppFlow.LOGIN
flow._reconnecting_game = false
client.char_list.emit([])
_ck(flow.state() == AppFlow.SELECT and not flow._reconnecting_game
and flow._game == null and client.selects.is_empty(),
"empty character list returns to the SELECT owner")
# 40250 ClosePhase/SetLoginPhase also owns the UI when a close arrives
# before GAME. The phase-only bridge event must not leave the old SELECT
# owner visible after the loading mask is dismissed.
var select_ui_before_close: Node = flow._ui
client.phase_changed.emit("login")
_ck(flow.state() == AppFlow.LOGIN and flow._ui != select_ui_before_close,
"pre-game LoginPhase transition replaces the SELECT owner")
_ck(client.server_quit_requested.is_connected(Callable(flow, "_on_server_quit_requested")),
"server quit command is wired to the application lifecycle owner")
# Keep the following auto-login checks independent when the assertion above
# is intentionally run against a pre-fix implementation.
flow._reconnecting_game = false
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
# character, and the chosen slot must be remembered for a later reconnect.
flow._state = AppFlow.LOGIN
flow.auto_login = true
flow._auto_char_slot = 5
flow._selected_char_slot = -1
client.selects.clear()
client.char_list.emit([{"index": 0, "name": "Other"}])
_ck(client.selects.is_empty(), "absent explicit slot does not enter chars[0]")
flow._state = AppFlow.LOGIN
client.char_list.emit([{"index": 0, "name": "Other"}, {"index": 5, "name": "Fixture"}])
_ck(client.selects == [5] and flow._selected_char_slot == 5,
"explicit slot is entered and remembered for reconnect")
flow.queue_free()
await process_frame
if _fail == 0:
print("PASS: app_flow_lifecycle_test (single lifecycle owner)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)