缺陷: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。
539 lines
18 KiB
GDScript
539 lines
18 KiB
GDScript
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()
|