extends TestCase ## T-WIN-003:WebSocket 关键点接收服务 ## ## 这是真实的 socket 端到端测试:在本机起 PoseServer, ## 用 WebSocketPeer 作为客户端连入,走完整的握手 → 二进制帧 → 控制帧流程。 const PoseServer = preload("res://src/net/pose_server.gd") const KeypointFrame = preload("res://src/net/keypoint_frame.gd") const TEST_PORT := 18765 const SUBPROTOCOL := "opendance.pose.v1" var _server: PoseServer = null var _client: WebSocketPeer = null var _received: Array = [] var _controls: Array = [] var _connected := 0 func _tree() -> SceneTree: return Engine.get_main_loop() as SceneTree ## 推进若干帧,让服务端与客户端都有机会 poll func _pump(frames: int = 1) -> void: for i in frames: await _tree().process_frame func _setup() -> void: _received.clear() _controls.clear() _connected = 0 _server = PoseServer.new() _server.subprotocol = SUBPROTOCOL # 缩短 degraded 保持时长:无头测试不能依赖真实等待 3 秒 _server.degraded_hold_ms = 60 _tree().root.add_child(_server) _server.frame_received.connect(func(frame): _received.append(frame)) _server.control_received.connect(func(_id, msg): _controls.append(msg)) _server.client_connected.connect(func(_id, _device): _connected += 1) _server.listen("127.0.0.1", TEST_PORT) _client = WebSocketPeer.new() _client.supported_protocols = PackedStringArray([SUBPROTOCOL]) _client.connect_to_url("ws://127.0.0.1:%d/pose" % TEST_PORT) # 等待握手完成 var guard := 0 while _client.get_ready_state() == WebSocketPeer.STATE_CONNECTING and guard < 240: _client.poll() await _pump() guard += 1 func _teardown() -> void: if _client != null: _client.close() _client = null if _server != null: _server.stop() _server.queue_free() _server = null await _pump(2) func _sample_points(offset: float = 0.0) -> PackedVector3Array: var points := PackedVector3Array() points.resize(33) for i in 33: points[i] = Vector3( clampf(0.5 + offset + float(i % 9) * 0.01, 0.0, 1.0), clampf(0.5 + float(i % 7) * 0.02, 0.0, 1.0), float((i % 5) - 2) * 0.1 ) return points func _sample_confidence() -> PackedFloat32Array: var conf := PackedFloat32Array() conf.resize(33) conf.fill(1.0) return conf ## 把客户端收到的包取出来 func _drain_client() -> Array: var out: Array = [] while _client.get_available_packet_count() > 0: var packet := _client.get_packet() out.append({ "text": packet.get_string_from_utf8() if _client.was_string_packet() else "", "binary": packet if not _client.was_string_packet() else PackedByteArray(), "is_text": _client.was_string_packet(), }) return out func test_listen_and_handshake() -> void: await _setup() is_true(_server.is_running(), "服务端进入监听状态") eq(_server.port(), TEST_PORT, "监听端口正确") eq(_client.get_ready_state(), WebSocketPeer.STATE_OPEN, "客户端握手成功(STATE_OPEN)") await _pump(3) eq(_connected, 1, "服务端发出 client_connected 信号") eq(_server.connected_count(), 1, "已握手连接数为 1") await _teardown() func test_subprotocol_is_negotiated() -> void: await _setup() eq(_client.get_selected_protocol(), SUBPROTOCOL, "子协议协商为 opendance.pose.v1") await _teardown() func test_hello_gets_welcome() -> void: await _setup() _client.send_text(JSON.stringify({ "type": "hello", "protocol": SUBPROTOCOL, "device": "Pixel 8 (测试)", "appVersion": "0.1.0", "cameraFacing": "front", "targetFps": 30, "resolution": "640x480", })) var got_welcome := false var welcome: Dictionary = {} for i in 60: _client.poll() await _pump() for packet in _drain_client(): if packet["is_text"]: var parsed: Variant = JSON.parse_string(packet["text"]) if typeof(parsed) == TYPE_DICTIONARY and str((parsed as Dictionary).get("type", "")) == "welcome": welcome = parsed got_welcome = true if got_welcome: break is_true(got_welcome, "收到 welcome 控制帧") eq(str(welcome.get("protocol", "")), SUBPROTOCOL, "welcome 回显协议版本") ok(welcome.has("sessionId"), "welcome 含 sessionId") ok(_controls.size() > 0, "服务端发出 control_received 信号") eq(str(_controls[0].get("device", "")), "Pixel 8 (测试)", "hello 中的设备名被记录") await _teardown() func test_binary_frame_is_received_and_decoded() -> void: await _setup() var points := _sample_points() var conf := _sample_confidence() _client.send(KeypointFrame.encode(1, points, conf, 0)) var got := false for i in 60: _client.poll() await _pump() if _received.size() > 0: got = true break is_true(got, "服务端收到并解析出关键点帧") if got: var frame: KeypointFrame.PoseFrame = _received[0] eq(frame.frame_id, 1, "frame_id 解析正确") eq(frame.points.size(), 33, "33 个关键点") var max_delta := 0.0 for i in 33: max_delta = maxf(max_delta, absf(frame.points[i].x - points[i].x)) ok(max_delta < 1e-4, "坐标经网络往返后仍在定点精度内", "最大误差 %.8f" % max_delta) eq(_server.frames_total, 1, "累计帧计数为 1") eq(_server.malformed_frames, 0, "无畸形帧") await _teardown() func test_malformed_frame_is_rejected_without_crashing() -> void: await _setup() # 长度错误 _client.send(PackedByteArray([1, 2, 3])) # magic 错误但长度正确 var bad := PackedByteArray() bad.resize(239) _client.send(bad) # 正常帧,用于确认连接未被破坏 _client.send(KeypointFrame.encode(1, _sample_points(), _sample_confidence(), 0)) var good_arrived := false for i in 90: _client.poll() await _pump() if _received.size() > 0: good_arrived = true break eq(_server.malformed_frames, 2, "两个畸形帧被拒绝并计数") is_true(good_arrived, "畸形帧之后连接仍可正常收帧") eq(_server.frames_total, 1, "仅有效帧计入累计") await _teardown() func test_frame_id_gap_is_counted_as_dropped() -> void: await _setup() var conf := _sample_confidence() _client.send(KeypointFrame.encode(1, _sample_points(), conf)) _client.send(KeypointFrame.encode(2, _sample_points(0.01), conf)) # 跳过 3、4、5 _client.send(KeypointFrame.encode(6, _sample_points(0.02), conf)) var guard := 0 while _received.size() < 3 and guard < 120: _client.poll() await _pump() guard += 1 eq(_received.size(), 3, "收到 3 帧") eq(_server.frames_dropped, 3, "frame_id 跳号被计为丢包(3 帧)") eq(_server.frames_total, 3, "累计收到 3 帧") await _teardown() func test_upper_body_and_mirror_flags_survive_transport() -> void: await _setup() var flags := KeypointFrame.FLAG_MIRRORED | KeypointFrame.FLAG_UPPER_BODY_ONLY _client.send(KeypointFrame.encode(7, _sample_points(), _sample_confidence(), flags)) var guard := 0 while _received.is_empty() and guard < 90: _client.poll() await _pump() guard += 1 if _received.is_empty(): ok(false, "未收到帧") else: var frame: KeypointFrame.PoseFrame = _received[0] is_true(frame.is_mirrored(), "镜像标志经网络传输保留") is_true(frame.is_upper_body_only(), "上半身模式标志经网络传输保留") await _teardown() func test_config_broadcast_reaches_client() -> void: await _setup() await _pump(3) _server.mirror = true _server.expected_fps = 24 _server.broadcast_config() var config: Dictionary = {} for i in 60: _client.poll() await _pump() for packet in _drain_client(): if packet["is_text"]: var parsed: Variant = JSON.parse_string(packet["text"]) if typeof(parsed) == TYPE_DICTIONARY and str((parsed as Dictionary).get("type", "")) == "config": config = parsed if not config.is_empty(): break is_false(config.is_empty(), "客户端收到 config 控制帧") eq(bool(config.get("mirror", false)), true, "config.mirror 正确") eq(int(config.get("targetFps", 0)), 24, "config.targetFps 正确") ok(config.has("upperBodyOnly"), "config 含 upperBodyOnly") ok(config.has("resolution"), "config 含 resolution") await _teardown() func test_snapshot_reports_state() -> void: await _setup() await _pump(3) var snap := _server.snapshot() is_true(bool(snap["running"]), "snapshot 报告运行中") eq(int(snap["port"]), TEST_PORT, "snapshot 报告端口") eq(int(snap["connected"]), 1, "snapshot 报告已连接 1 个客户端") await _teardown() func test_stop_closes_listener() -> void: await _setup() _server.stop() is_false(_server.is_running(), "stop() 后不再监听") eq(_server.connected_count(), 0, "stop() 后连接清空") await _teardown() # ---------------- T-AND-011:契约 v0.2.0 的保活 / 去重 / 状态机 ---------------- ## 连入第二个客户端(用于 deviceId 去重测试) func _open_second_client() -> WebSocketPeer: var peer := WebSocketPeer.new() peer.supported_protocols = PackedStringArray([SUBPROTOCOL]) peer.connect_to_url("ws://127.0.0.1:%d/pose" % TEST_PORT) var guard := 0 while peer.get_ready_state() == WebSocketPeer.STATE_CONNECTING and guard < 240: peer.poll() await _pump() guard += 1 return peer func _send_hello(peer: WebSocketPeer, device_id: String, device: String = "测试机") -> void: peer.send_text(JSON.stringify({ "type": "hello", "protocol": SUBPROTOCOL, "deviceId": device_id, "device": device, "appVersion": "0.2.0", "cameraFacing": "front", "targetFps": 30, "resolution": "640x480", })) ## 等某个客户端收到指定类型的控制帧 func _await_control(peer: WebSocketPeer, type: String, max_frames: int = 90) -> Dictionary: for i in max_frames: peer.poll() await _pump() while peer.get_available_packet_count() > 0: var packet := peer.get_packet() if not peer.was_string_packet(): continue var parsed: Variant = JSON.parse_string(packet.get_string_from_utf8()) if typeof(parsed) == TYPE_DICTIONARY and str((parsed as Dictionary).get("type", "")) == type: return parsed return {} func test_welcome_declares_keepalive_parameters() -> void: await _setup() await _pump(3) _send_hello(_client, "device-keepalive") var welcome := await _await_control(_client, "welcome") is_false(welcome.is_empty(), "收到 welcome 控制帧") var timeout_ms := int(welcome.get("silentTimeoutMs", 0)) var heartbeat_ms := int(welcome.get("heartbeatIntervalMs", 0)) ok(timeout_ms > 0, "welcome 声明 silentTimeoutMs", str(timeout_ms)) ok(heartbeat_ms > 0, "welcome 声明 heartbeatIntervalMs", str(heartbeat_ms)) # 契约 §1.1 硬性要求:心跳间隔必须小于静默超时的一半 ok(heartbeat_ms * 2 < timeout_ms, "心跳间隔小于静默超时的一半", "心跳 %d ms,超时 %d ms" % [heartbeat_ms, timeout_ms]) await _teardown() func test_ping_gets_pong_with_echoed_timestamp() -> void: await _setup() await _pump(3) _send_hello(_client, "device-ping") await _await_control(_client, "welcome") _client.send_text(JSON.stringify({"type": "ping", "sentAtMs": 1730000000123})) var pong := await _await_control(_client, "pong") is_false(pong.is_empty(), "收到 pong 控制帧(应用层心跳可续命)") eq(int(pong.get("sentAtMs", 0)), 1730000000123, "pong 原样回传 sentAtMs") ok(int(pong.get("serverAtMs", 0)) > 0, "pong 含服务端时间戳") await _teardown() func test_duplicate_device_id_is_deduplicated() -> void: await _setup() await _pump(3) _send_hello(_client, "device-dup", "第一台") await _await_control(_client, "welcome") await _pump(3) eq(_server.device_count(), 1, "去重前设备数为 1") var second := await _open_second_client() eq(second.get_ready_state(), WebSocketPeer.STATE_OPEN, "第二台客户端握手成功") _send_hello(second, "device-dup", "第一台(重连)") await _await_control(second, "welcome") # 服务端应在若干帧内关掉旧连接,并以 1000 关闭(对端不重连) var guard := 0 while guard < 120 and _client.get_ready_state() != WebSocketPeer.STATE_CLOSED: _client.poll() await _pump() guard += 1 eq(_client.get_ready_state(), WebSocketPeer.STATE_CLOSED, "同一 deviceId 的旧连接被关闭") eq(_client.get_close_code(), 1000, "旧连接以 1000 关闭(契约 §7:对端不重连)") eq(_server.device_count(), 1, "去重后设备数仍为 1(不因重连变多)") is_true(_server.connected_count() >= 1, "新连接仍在") second.close() await _pump(2) await _teardown() func test_states_follow_contract_names() -> void: eq(PoseServer.state_name(PoseServer.ClientState.IDLE), "idle", "idle 状态名") eq(PoseServer.state_name(PoseServer.ClientState.RESOLVING), "resolving", "resolving 状态名") eq(PoseServer.state_name(PoseServer.ClientState.CONNECTING), "connecting", "connecting 状态名") eq(PoseServer.state_name(PoseServer.ClientState.HANDSHAKING), "handshaking", "handshaking 状态名") eq(PoseServer.state_name(PoseServer.ClientState.STREAMING), "streaming", "streaming 状态名") eq(PoseServer.state_name(PoseServer.ClientState.DEGRADED), "degraded", "degraded 状态名") eq(PoseServer.state_name(PoseServer.ClientState.RECONNECTING), "reconnecting", "reconnecting 状态名") eq(PoseServer.state_name(PoseServer.ClientState.STOPPED), "stopped", "stopped 状态名") func test_client_state_progresses_to_streaming() -> void: await _setup() await _pump(3) _send_hello(_client, "device-state") await _await_control(_client, "welcome") var info_before: Array = _server.clients_info() eq(info_before.size(), 1, "clients_info 含 1 个客户端") eq(str(info_before[0]["state"]), "handshaking", "只发 hello 时为 handshaking") _client.send(KeypointFrame.encode(0, _sample_points(), _sample_confidence())) var guard := 0 while _received.is_empty() and guard < 90: _client.poll() await _pump() guard += 1 var info: Array = _server.clients_info() eq(str(info[0]["state"]), "streaming", "收到首帧后进入 streaming") eq(str(info[0]["device_id"]), "device-state", "deviceId 被记录") eq(str(info[0]["protocol"]), SUBPROTOCOL, "记录协商后的子协议") ok(int(info[0]["first_frame_at_ms"]) > 0, "记录首帧时间(可诊断)") await _teardown() func test_frame_gap_marks_degraded_then_recovers() -> void: await _setup() await _pump(3) _send_hello(_client, "device-degraded") await _await_control(_client, "welcome") _client.send(KeypointFrame.encode(0, _sample_points(), _sample_confidence())) _client.send(KeypointFrame.encode(9, _sample_points(0.01), _sample_confidence())) var guard := 0 while _server.frames_dropped == 0 and guard < 120: _client.poll() await _pump() guard += 1 eq(_server.frames_dropped, 8, "跳号 1~8 被计为丢包") eq(str(_server.clients_info()[0]["state"]), "degraded", "出现丢帧时进入 degraded") # 无新丢帧持续 DEGRADED_HOLD_MS 后回到 streaming var waited := 0 while waited < 400 and str(_server.clients_info()[0]["state"]) != "streaming": _client.poll() await _pump() waited += 1 eq(str(_server.clients_info()[0]["state"]), "streaming", "丢帧停止后回到 streaming") await _teardown() func test_snapshot_exposes_keepalive_and_device_count() -> void: await _setup() await _pump(3) _send_hello(_client, "device-snapshot") await _await_control(_client, "welcome") await _pump(3) var snap := _server.snapshot() eq(int(snap["devices"]), 1, "snapshot 报告去重后设备数") eq(int(snap["silent_timeout_ms"]), PoseServer.CLIENT_TIMEOUT_MS, "snapshot 报告静默超时") eq(int(snap["heartbeat_interval_ms"]), PoseServer.HEARTBEAT_INTERVAL_MS, "snapshot 报告心跳间隔") await _teardown()