# 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)