Files
opendance-windows/tests/test_pose_server.gd
T
zijunchiang 14ee5f7ec9 fix(scene): 包围盒沿树累乘变换(修掉入树前的 ERROR 与范围偏位)+ 21 项"真机等价"测试
缺陷:SceneRuntime._compute_aabb() 用 MeshInstance3D.global_transform 计算包围盒,
而它在 _assemble() 里调用 —— 那时运行时节点**还没入树**。两个后果:
- Godot 打一行 `ERROR: Condition "!is_inside_tree()" is true. Returning: Transform3D()`
  (真机每次载入场景都刷一条,与"日志里不应有 ERROR"的排查约定冲突);
- 树外的 global_transform 返回单位变换,算出来的只是各网格的**局部** AABB,
  于是重力/风 Area3D 的碰撞盒与兜底地面会偏小或偏位 —— 对把网格摆在非原点位置的
  场景(导出 GLB 的常见情况)**风力可能作用不到物体上**。

修复:改为从根往下累乘 `Node3D.transform`,不依赖是否入树;挂在单位变换父节点下时
结果与旧语义一致。

新增 21 项无头"真机等价"测试(不需要设备):
- tests/test_scene_config.gd:在代码里手写合法 GLB(三角形 + BIN chunk)落盘后
  **真实加载**:warnings 为空、按 colliders 三种取值分别核对碰撞体(mesh / auto 命中
  *_col / auto 未命中的兜底地面 / none)、重力 Area3D 的 gravity 与方向、
  SPACE_OVERRIDE_REPLACE、风力与 WIND_FORCE_SCALE 对齐,以及**带位移网格**
  (translation [10,0,0])时重力区域与兜底地面都跟着位移(该缺陷的回归测试);
- tests/test_pose_server.gd:**时间维度**验证保活 —— 只发应用层 ping、不发关键点帧的
  客户端在 17 秒(> 15 秒静默超时)后仍保持连接("画面里没人就掉线、疯狂重连"的根因
  对照),而完全不发数据的客户端被 close(1001) 断开。

新增 build/check-version.sh:强制 project.godot 的 application/config/version
(「关于」清单与 .pck 的来源)与 project.yaml、发布版本号三者一致;已接入
build/package-release.sh(打包前)与 build/verify.sh(第 0/4 步),并做过反向验证。
此前没有任何机制保证这两个文件同步 —— 界面显示的版本可能和产物名不一致。

版本升至 0.2.1(project.godot / project.yaml / README / 产物名 / tag 全部一致),
About 清单与 .pck 内版本号均为 0.2.1(已用 strings 核对)。

验证:build/verify.sh 四步全通过(版本一致性 → 编译 → 751 项断言 → 180 帧启动无错误);
产物 opendance-windows-v0.2.1.zip(LZMA,26,627,191 B)
sha256 d49885327047f5a52de6c732431ea080cd1a030ae1c581a8bc0ec0d99bfb792c。
2026-09-17 03:59:29 +08:00

539 lines
18 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()
# ---------------- T-AND-011 §6.5:保活的**时间维度**验证 ----------------
#
# 现有用例只验证了"welcome 声明了保活参数""ping 能换到 pong",但没有验证
# **时间维度**上的行为 —— 而"频繁重连"恰恰是时间维度的问题:
# 修复前:服务端静默超时 5s,客户端只有协议层 ping(应用层看不到),
# 于是"画面里没有人"时每 5 秒被踢一次,再重连,循环往复。
# 这里用**真实的 15 秒静默超时**跑一次端到端对照:
# A) 只发应用层 ping、不发任何关键点帧 → 必须**不被断开**;
# B) 连上后什么都不发 → 必须被静默超时踢掉(close 1001)。
# 两个客户端并行跑,总耗时约一个超时周期。
func test_keepalive_survives_silence_but_idle_client_is_dropped() -> void:
await _setup()
await _pump(3)
# A:只发 ping 的客户端
_send_hello(_client, "device-keepalive-only")
await _await_control(_client, "welcome")
# B:连上但什么都不发的客户端
var idle := await _open_second_client()
_send_hello(idle, "device-idle")
await _await_control(idle, "welcome")
eq(idle.get_ready_state(), WebSocketPeer.STATE_OPEN, "闲置客户端初始为已连接")
var timeout_ms := PoseServer.CLIENT_TIMEOUT_MS
var heartbeat_ms := PoseServer.HEARTBEAT_INTERVAL_MS
# 契约 §1.1:心跳必须小于静默超时的一半(否则必然被误判掉线)
ok(heartbeat_ms * 2 < timeout_ms, "心跳间隔小于静默超时的一半",
"心跳 %d ms,超时 %d ms" % [heartbeat_ms, timeout_ms])
# 跑满一个完整的静默超时周期再多一点(真实时钟,不是帧数)
var started := Time.get_ticks_msec()
var guard := 0
while Time.get_ticks_msec() - started < timeout_ms + 2000 and guard < 200000:
# A 按服务端声明的心跳节奏发应用层 ping(不发关键点帧)
var elapsed := Time.get_ticks_msec() - started
if elapsed / heartbeat_ms != (elapsed - 16) / heartbeat_ms:
_client.send_text(JSON.stringify({"type": "ping", "sentAtMs": Time.get_ticks_msec()}))
_client.poll()
idle.poll()
await _pump()
guard += 1
var elapsed_ms := Time.get_ticks_msec() - started
eq(_client.get_ready_state(), WebSocketPeer.STATE_OPEN,
"只发心跳、不发关键点帧的客户端在 %d ms 后仍保持连接(频繁重连的根因已修复)" % elapsed_ms)
eq(idle.get_ready_state(), WebSocketPeer.STATE_CLOSED,
"完全不发数据的客户端被静默超时断开(超时机制仍然有效)")
if idle.get_ready_state() == WebSocketPeer.STATE_CLOSED:
eq(idle.get_close_code(), 1001, "静默超时以 1001 关闭(契约 §7:对端不重连)")
await _teardown()