Files
opendance-windows/tests/test_pose_server.gd
T
zijunchiang c3a227c6df feat(net): 连接保活 / 设备去重 / 连接状态机(T-WIN-015,契约 websocket-keypoints 0.2.0)
与 Android 端 T-AND-011「两端一起改」:契约先在 opendance-web 修改并提升到
websocket-keypoints 0.2.0(向后兼容:子协议名与二进制帧 version 均不变,
只新增可选控制字段与一种新控制类型),再同步三端快照。

修掉本端的 3 处根因/放大器:
1. 静默超时 5s 过短且未声明,客户端无从得知。OkHttp 的协议层 ping 由底层库
   自动应答、**不暴露给应用层**,无法刷新 last_seen_ms,于是"画面里没人"时
   每 5 秒必然踢一次连接 -> 客户端重连 -> 再被踢。
   - CLIENT_TIMEOUT_MS 5s -> 15s,新增 HEARTBEAT_INTERVAL_MS = 5s;
   - welcome_payload() 在 welcome 中声明 silentTimeoutMs / heartbeatIntervalMs,
     config 广播同样带上(契约 §1.1:心跳必须 < 静默超时的一半,5000*2 < 15000);
   - 处理应用层 ping 并回 pong(回显 sentAtMs + serverAtMs,供对端算 RTT)。
2. 反复重连被计成多台设备。按契约 §5 用 hello.deviceId 去重:同一设备只保留
   最新连接,旧连接以 close(1000) 关闭——刻意用 1000,因为对端按契约 §7
   收到 1000 不会重连,否则会形成"挤掉-重连-再挤掉"的循环;
   新增 device_count() 与 device_replaced 信号,界面一律显示去重后的设备数。
3. 只有 handshaken 布尔量,日志看不出卡在哪一步。按契约 §8 逐客户端维护与
   Android 端同名的状态(connecting -> handshaking -> streaming -> degraded,
   主动停止/超时/被替换为 stopped),并记录 close code/reason、协商子协议、
   连接/握手/首帧时间;degraded 保持时长做成可覆盖字段便于测试。

验收测试(tests/test_pose_server.gd 新增 7 项,总计 720 通过 / 0 失败):
welcome 声明保活参数、ping->pong 回显、同 deviceId 去重(旧连接 1000 关闭且
设备数仍为 1)、8 个状态名与契约一致、状态随握手/首帧推进到 streaming、
丢帧进入 degraded 后恢复、snapshot 暴露设备数与保活参数。

同时更新 README/README.txt/tasks 与 Credits 的契约版本(0.2.0)。
2026-09-17 01:11:48 +08:00

485 lines
15 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.
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()