- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
236 lines
12 KiB
GDScript
236 lines
12 KiB
GDScript
# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。
|
||
# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycle;GameScene 不重复创建。
|
||
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)
|